Spaces:
Runtime error
Runtime error
Commit ·
dbf5e96
0
Parent(s):
CodeReviewEnv v1.0 — OpenEnv-compliant submission
Browse files- 3 tasks (easy/medium/hard) with deterministic graders
- Spec-compliant inference.py with [START]/[STEP]/[END] per-task logs
- Root-level baseline.py (heuristic, no API key needed)
- Fixed reward double-counting bug in _apply_reward_shaping
- 17/17 validation checks, 21/21 tests passing
- Docker-ready on port 7860 for HF Spaces
- .dockerignore +22 -0
- .gitignore +25 -0
- Dockerfile +16 -0
- README.md +568 -0
- __init__.py +22 -0
- analysis/__init__.py +312 -0
- analysis/agent_profiler.py +9 -0
- baseline.py +190 -0
- baseline/heuristic_results.json +34 -0
- baseline/live_results.json +35 -0
- baseline/results.json +22 -0
- baseline/run_baseline.py +163 -0
- benchmark/__init__.py +1 -0
- benchmark/agents.py +184 -0
- benchmark/protocol.py +226 -0
- client.py +51 -0
- env/__init__.py +12 -0
- env/base.py +448 -0
- env/data_generator.py +1118 -0
- env/models.py +128 -0
- env/trajectory_logger.py +112 -0
- eval_live.py +648 -0
- graders/__init__.py +12 -0
- graders/grader_easy.py +232 -0
- graders/grader_hard.py +311 -0
- graders/grader_medium.py +261 -0
- graders/reliability.py +242 -0
- inference.py +484 -0
- models.py +107 -0
- openenv.yaml +66 -0
- paper/outline.md +156 -0
- pyproject.toml +50 -0
- requirements-research.txt +5 -0
- requirements.txt +8 -0
- research_note.md +97 -0
- server/__init__.py +4 -0
- server/app.py +46 -0
- server/code_review_environment.py +356 -0
- tasks/__init__.py +13 -0
- tasks/task_easy.py +89 -0
- tasks/task_hard.py +133 -0
- tasks/task_medium.py +97 -0
- tests/__init__.py +1 -0
- tests/test_env.py +345 -0
- validate.py +328 -0
- world_model/__init__.py +1 -0
- world_model/scaffold.py +356 -0
.dockerignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
.pytest_cache
|
| 5 |
+
.git
|
| 6 |
+
.gitignore
|
| 7 |
+
*.egg-info
|
| 8 |
+
dist
|
| 9 |
+
build
|
| 10 |
+
trajectories/
|
| 11 |
+
outputs/
|
| 12 |
+
*.md
|
| 13 |
+
!README.md
|
| 14 |
+
requirements-research.txt
|
| 15 |
+
world_model/
|
| 16 |
+
paper/
|
| 17 |
+
baseline/
|
| 18 |
+
analysis/
|
| 19 |
+
benchmark/
|
| 20 |
+
tests/
|
| 21 |
+
validate.py
|
| 22 |
+
.dockerignore
|
.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.egg-info/
|
| 6 |
+
dist/
|
| 7 |
+
build/
|
| 8 |
+
|
| 9 |
+
# Testing
|
| 10 |
+
.pytest_cache/
|
| 11 |
+
.hypothesis/
|
| 12 |
+
|
| 13 |
+
# Runtime outputs
|
| 14 |
+
trajectories/
|
| 15 |
+
outputs/
|
| 16 |
+
|
| 17 |
+
# IDE
|
| 18 |
+
.vscode/
|
| 19 |
+
.idea/
|
| 20 |
+
|
| 21 |
+
# OS
|
| 22 |
+
.DS_Store
|
| 23 |
+
|
| 24 |
+
# Lock files (not needed for HF Space)
|
| 25 |
+
uv.lock
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# HuggingFace Spaces expects port 7860
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install dependencies (openenv-core brings FastAPI, uvicorn, pydantic)
|
| 7 |
+
COPY requirements.txt .
|
| 8 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 9 |
+
|
| 10 |
+
# Copy environment code
|
| 11 |
+
COPY . .
|
| 12 |
+
|
| 13 |
+
EXPOSE 7860
|
| 14 |
+
|
| 15 |
+
# Use uvicorn to serve the OpenEnv app on HF Spaces port
|
| 16 |
+
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: CodeReviewEnv
|
| 3 |
+
emoji: 🔍
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
tags:
|
| 9 |
+
- openenv
|
| 10 |
+
- reinforcement-learning
|
| 11 |
+
- code-review
|
| 12 |
+
- mbrl
|
| 13 |
+
- knowledge-work
|
| 14 |
+
- llm-agents
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
# 🔍 CodeReviewEnv
|
| 18 |
+
|
| 19 |
+
**An OpenEnv-compliant RL environment for software code review agents.**
|
| 20 |
+
|
| 21 |
+
Train and evaluate LLM agents on real code review tasks — severity triage, queue prioritization, and actionable feedback generation — with deterministic grading, shaped rewards, and trajectory logging for semantic world model research.
|
| 22 |
+
|
| 23 |
+
[](https://github.com/openenv)
|
| 24 |
+
[](https://www.python.org/downloads/)
|
| 25 |
+
[](LICENSE)
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## Table of Contents
|
| 30 |
+
|
| 31 |
+
- [Motivation](#motivation)
|
| 32 |
+
- [Environment Description](#environment-description)
|
| 33 |
+
- [Observation Space](#observation-space)
|
| 34 |
+
- [Action Space](#action-space)
|
| 35 |
+
- [Reward Design](#reward-design)
|
| 36 |
+
- [Tasks](#tasks)
|
| 37 |
+
- [Baseline Scores](#baseline-scores)
|
| 38 |
+
- [Setup & Installation](#setup--installation)
|
| 39 |
+
- [Usage](#usage)
|
| 40 |
+
- [Running Inference](#running-inference)
|
| 41 |
+
- [Environment Variables](#environment-variables)
|
| 42 |
+
- [Pre-Submission Checklist](#pre-submission-checklist)
|
| 43 |
+
- [Structured Logging Format](#structured-logging-format)
|
| 44 |
+
- [Project Structure](#project-structure)
|
| 45 |
+
- [Trajectory Dataset](#trajectory-dataset)
|
| 46 |
+
- [Citation](#citation)
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## Motivation
|
| 51 |
+
|
| 52 |
+
### The Problem
|
| 53 |
+
|
| 54 |
+
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.
|
| 55 |
+
|
| 56 |
+
### Why CodeReviewEnv?
|
| 57 |
+
|
| 58 |
+
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:
|
| 59 |
+
|
| 60 |
+
| Need | CodeReviewEnv |
|
| 61 |
+
|------|---------------|
|
| 62 |
+
| Real task that people do daily | ✅ Software code review |
|
| 63 |
+
| MDP formalism with R(s,a,s') | ✅ Semantic MDP with shaped rewards |
|
| 64 |
+
| Multiple difficulty levels | ✅ Easy / Medium / Hard |
|
| 65 |
+
| Deterministic, reproducible grading | ✅ Seed-controlled, no stochastic graders |
|
| 66 |
+
| Trajectory export for MBRL | ✅ JSONL `(s, a, r, s')` per step |
|
| 67 |
+
| Deployable as a service | ✅ Docker + HF Space + OpenEnv spec |
|
| 68 |
+
|
| 69 |
+
### Research Gap
|
| 70 |
+
|
| 71 |
+
| Benchmark | State Space | Transition | World Model Support? |
|
| 72 |
+
|-----------|-------------|------------|---------------------|
|
| 73 |
+
| MuJoCo | ℝⁿ (joints) | Physics sim | Yes (Dreamer) |
|
| 74 |
+
| Atari | Pixels | Game engine | Yes (MuZero) |
|
| 75 |
+
| AgentBench | Text | N/A | No (eval only) |
|
| 76 |
+
| SWE-bench | Code | N/A | No (eval only) |
|
| 77 |
+
| **CodeReviewEnv** | **Semantic text** | **Semantic** | **Yes (this work)** |
|
| 78 |
+
|
| 79 |
+
CodeReviewEnv introduces **semantic transitions** — the state is structured text (code diffs, bug patterns, author context) and the transition depends on *understanding meaning*. This enables a new class of **semantic world models** not benchmarked elsewhere.
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## Environment Description
|
| 84 |
+
|
| 85 |
+
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.
|
| 86 |
+
|
| 87 |
+
### Episode Flow
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
reset(seed) → Observation₀
|
| 91 |
+
↓
|
| 92 |
+
step(Action₁) → (Observation₁, Reward₁, done₁, info₁)
|
| 93 |
+
step(Action₂) → (Observation₂, Reward₂, done₂, info₂)
|
| 94 |
+
...
|
| 95 |
+
step(Actionₙ) → (Observationₙ, Rewardₙ, done=True, infoₙ)
|
| 96 |
+
↓
|
| 97 |
+
export_trajectory() → [(s₀, a₁, r₁, s₁), (s₁, a₂, r₂, s₂), ...]
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
- **`reset(seed)`** produces a clean initial state — no leakage between episodes
|
| 101 |
+
- **`step(action)`** returns the standard `(observation, reward, done, info)` tuple
|
| 102 |
+
- **`state()`** exposes the full internal state including trajectory history
|
| 103 |
+
- **`export_trajectory()`** outputs `(s, a, r, s')` transitions in JSONL
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## Observation Space
|
| 108 |
+
|
| 109 |
+
The observation represents the semantic state `s ∈ S` visible to the agent at each timestep.
|
| 110 |
+
|
| 111 |
+
| Field | Type | Description |
|
| 112 |
+
|-------|------|-------------|
|
| 113 |
+
| `pr_id` | `str` | Unique pull request identifier (e.g. `PR-001`) |
|
| 114 |
+
| `title` | `str` | Human-readable PR title |
|
| 115 |
+
| `description` | `str` | PR description / summary |
|
| 116 |
+
| `author_experience` | `str ∈ {junior, mid, senior}` | Experience level of the PR author |
|
| 117 |
+
| `files` | `List[PRFile]` | Code diffs per file (see below) |
|
| 118 |
+
| `existing_comments` | `List[str]` | Previously submitted review comments |
|
| 119 |
+
| `review_queue` | `List[str]` | IDs of pending PRs in queue |
|
| 120 |
+
| `step_number` | `int` | Current step in the episode (0-indexed) |
|
| 121 |
+
| `episode_budget` | `int` | Steps remaining in this episode |
|
| 122 |
+
|
| 123 |
+
### PRFile Schema
|
| 124 |
+
|
| 125 |
+
Each file in `files` contains:
|
| 126 |
+
|
| 127 |
+
| Field | Type | Description |
|
| 128 |
+
|-------|------|-------------|
|
| 129 |
+
| `filename` | `str` | File path (e.g. `UserService.java`) |
|
| 130 |
+
| `language` | `str ∈ {python, javascript, java, go}` | Programming language |
|
| 131 |
+
| `diff` | `str` | Unified diff of changes |
|
| 132 |
+
| `lines_changed` | `int` | Number of modified lines |
|
| 133 |
+
| `has_tests` | `bool` | Whether the PR includes test coverage |
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
## Action Space
|
| 138 |
+
|
| 139 |
+
The action space is **heterogeneous** — different `action_type` values activate different required fields. This mirrors real code review decisions.
|
| 140 |
+
|
| 141 |
+
| `action_type` | Required Fields | Description |
|
| 142 |
+
|---------------|----------------|-------------|
|
| 143 |
+
| `label_severity` | `severity ∈ {critical, high, medium, low, none}` | Classify the bug severity of the current PR |
|
| 144 |
+
| `prioritize` | `priority_order: List[str]` | Order the review queue by urgency (most urgent first) |
|
| 145 |
+
| `add_comment` | `comment: str`, `target_file: str`, `target_line: int` | Add a review comment targeting a specific line in a specific file |
|
| 146 |
+
| `approve` | — | Approve the PR (no remaining concerns) |
|
| 147 |
+
| `request_changes` | — | Request changes (bugs found, feedback given) |
|
| 148 |
+
|
| 149 |
+
### Action Validation
|
| 150 |
+
|
| 151 |
+
- Actions with an `action_type` mismatched to the current task receive a penalty reward
|
| 152 |
+
- Actions with missing required fields are handled gracefully (no crash, penalty applied)
|
| 153 |
+
- The environment never raises on malformed actions — it returns a penalty `Reward` instead
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## Reward Design
|
| 158 |
+
|
| 159 |
+
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.
|
| 160 |
+
|
| 161 |
+
| Component | Value | When Applied |
|
| 162 |
+
|-----------|-------|--------------|
|
| 163 |
+
| `step_reward` | `[0.0, 1.0]` | Per-action quality score from the task-specific grader |
|
| 164 |
+
| `efficiency_bonus` | `+0.10` | Complete the episode under budget |
|
| 165 |
+
| `coverage_bonus` | `+0.15` | Catch all critical bugs in the PR |
|
| 166 |
+
| `consistency_penalty` | `−0.20` | Contradict your own previous severity labels |
|
| 167 |
+
| `exploit_penalty` | `−0.50` | Approve a PR with unaddressed critical bugs |
|
| 168 |
+
|
| 169 |
+
### Reward Properties
|
| 170 |
+
|
| 171 |
+
- **Bounded**: All rewards clamped to `[-1.0, 1.0]`
|
| 172 |
+
- **Shaped**: Non-sparse signal at every step (not just at episode end)
|
| 173 |
+
- **Transparent**: `reward.breakdown` exposes all components for reward attribution research
|
| 174 |
+
- **Anti-exploit**: Spam comments and blind approvals are penalized (see tests)
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
## Tasks
|
| 179 |
+
|
| 180 |
+
CodeReviewEnv provides **3 tasks** spanning easy → hard, with different skills and grading logic:
|
| 181 |
+
|
| 182 |
+
### Task 1: Severity Labeling (Easy)
|
| 183 |
+
|
| 184 |
+
| Property | Value |
|
| 185 |
+
|----------|-------|
|
| 186 |
+
| Difficulty | ⭐ Easy |
|
| 187 |
+
| Episode Length | 5 steps (5 PRs) |
|
| 188 |
+
| Objective | Classify each PR's bug severity |
|
| 189 |
+
| Actions Used | `label_severity` |
|
| 190 |
+
| 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`. |
|
| 191 |
+
| Expected Score (random) | ~0.21 |
|
| 192 |
+
| Expected Score (GPT-4o-mini) | ~0.73 |
|
| 193 |
+
| Expected Score (perfect) | 1.00 |
|
| 194 |
+
|
| 195 |
+
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`.
|
| 196 |
+
|
| 197 |
+
### Task 2: Queue Prioritization (Medium)
|
| 198 |
+
|
| 199 |
+
| Property | Value |
|
| 200 |
+
|----------|-------|
|
| 201 |
+
| Difficulty | ⭐⭐ Medium |
|
| 202 |
+
| Episode Length | 3 steps (3 queues of 5 PRs each) |
|
| 203 |
+
| Objective | Sort the review queue by urgency |
|
| 204 |
+
| Actions Used | `prioritize` |
|
| 205 |
+
| Grader | Kendall Tau rank correlation + position penalty for misplacing top-priority items. Perfect ordering = 1.0, random ≈ 0.31, fully reversed ≈ 0.0. |
|
| 206 |
+
| Expected Score (random) | ~0.31 |
|
| 207 |
+
| Expected Score (GPT-4o-mini) | ~0.94 |
|
| 208 |
+
| Expected Score (perfect) | 1.00 |
|
| 209 |
+
|
| 210 |
+
The agent sees a queue of PRs with metadata (author level, bug category, test coverage) and must output the optimal priority ordering. Grading uses Kendall Tau correlation to smoothly rank all orderings.
|
| 211 |
+
|
| 212 |
+
### Task 3: Feedback Generation (Hard)
|
| 213 |
+
|
| 214 |
+
| Property | Value |
|
| 215 |
+
|----------|-------|
|
| 216 |
+
| Difficulty | ⭐⭐⭐ Hard |
|
| 217 |
+
| Episode Length | Up to 18 steps (3 PRs × ≤6 actions each) |
|
| 218 |
+
| Objective | Write actionable review comments, then approve or request changes |
|
| 219 |
+
| Actions Used | `add_comment`, `approve`, `request_changes` |
|
| 220 |
+
| 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). |
|
| 221 |
+
| Expected Score (random) | ~0.09 |
|
| 222 |
+
| Expected Score (GPT-4o-mini) | ~1.00 |
|
| 223 |
+
| Expected Score (perfect oracle) | ~0.91 |
|
| 224 |
+
|
| 225 |
+
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.
|
| 226 |
+
|
| 227 |
+
**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.
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
## Baseline Scores
|
| 232 |
+
|
| 233 |
+
Two baseline agents are included: a **heuristic agent** (no LLM, runs instantly via `baseline.py`) and an **LLM agent** (requires API key, via `inference.py`).
|
| 234 |
+
|
| 235 |
+
### Heuristic Baseline (`baseline.py`)
|
| 236 |
+
|
| 237 |
+
Run `python baseline.py` — no API key required.
|
| 238 |
+
|
| 239 |
+
| Agent | Easy | Medium | Hard | Composite |
|
| 240 |
+
|-------|------|--------|------|-----------|
|
| 241 |
+
| Keyword Heuristic | 0.80 ± 0.26 | 0.41 ± 0.08 | 0.69 ± 0.10 | 0.64 |
|
| 242 |
+
| Random | ~0.21 | ~0.31 | ~0.09 | ~0.18 |
|
| 243 |
+
|
| 244 |
+
### LLM Baseline (`inference.py`)
|
| 245 |
+
|
| 246 |
+
Run `python inference.py` with `API_BASE_URL`, `MODEL_NAME`, and `HF_TOKEN` set.
|
| 247 |
+
|
| 248 |
+
| Agent | Easy | Medium | Hard | Composite |
|
| 249 |
+
|-------|------|--------|------|-----------|
|
| 250 |
+
| GPT-4o-mini (estimated) | ~0.70 | ~0.90 | ~0.40 | ~0.67 |
|
| 251 |
+
| Perfect Oracle | 1.00 | 1.00 | ~0.91 | ~0.97 |
|
| 252 |
+
|
| 253 |
+
> **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`.
|
| 254 |
+
|
| 255 |
+
**Key observations:**
|
| 256 |
+
- Scores span the full `[0, 1]` range — the environment discriminates meaningfully
|
| 257 |
+
- Random agent scores well below 0.5 on all tasks — no trivial gaming
|
| 258 |
+
- Hard task genuinely challenges agents (random baseline scores only ~0.09)
|
| 259 |
+
- All scores are deterministic given the same seed and model
|
| 260 |
+
- Heuristic baseline runtime: < 2 seconds; LLM baseline: ~200 seconds
|
| 261 |
+
|
| 262 |
+
---
|
| 263 |
+
|
| 264 |
+
## Setup & Installation
|
| 265 |
+
|
| 266 |
+
### Requirements
|
| 267 |
+
|
| 268 |
+
- **Python 3.11+**
|
| 269 |
+
- An OpenAI-compatible API key (OpenRouter, OpenAI, etc.)
|
| 270 |
+
|
| 271 |
+
### Install Dependencies
|
| 272 |
+
|
| 273 |
+
```bash
|
| 274 |
+
# Clone the repository
|
| 275 |
+
git clone https://huggingface.co/spaces/openenv/code-review-env
|
| 276 |
+
cd code-review-env
|
| 277 |
+
|
| 278 |
+
# Install core dependencies
|
| 279 |
+
pip install -r requirements.txt
|
| 280 |
+
|
| 281 |
+
# (Optional) Install research dependencies for world model training
|
| 282 |
+
pip install -r requirements-research.txt
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
### Docker
|
| 286 |
+
|
| 287 |
+
```bash
|
| 288 |
+
# Build
|
| 289 |
+
docker build -t code-review-env .
|
| 290 |
+
|
| 291 |
+
# Run (serves on port 7860)
|
| 292 |
+
docker run -p 7860:7860 code-review-env
|
| 293 |
+
|
| 294 |
+
# Verify
|
| 295 |
+
curl http://localhost:7860/health
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
### Validate
|
| 299 |
+
|
| 300 |
+
```bash
|
| 301 |
+
# Run the full OpenEnv compliance validation suite
|
| 302 |
+
python validate.py
|
| 303 |
+
|
| 304 |
+
# Run the test suite (19 tests across 5 categories)
|
| 305 |
+
pytest tests/ -v
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
---
|
| 309 |
+
|
| 310 |
+
## Usage
|
| 311 |
+
|
| 312 |
+
### Direct Python API
|
| 313 |
+
|
| 314 |
+
```python
|
| 315 |
+
from env.base import CodeReviewEnv
|
| 316 |
+
from env.models import Action
|
| 317 |
+
|
| 318 |
+
# Initialize with task and seed
|
| 319 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 320 |
+
obs = env.reset()
|
| 321 |
+
|
| 322 |
+
# Take an action
|
| 323 |
+
action = Action(action_type="label_severity", severity="high")
|
| 324 |
+
obs, reward, done, info = env.step(action)
|
| 325 |
+
|
| 326 |
+
print(f"Reward: {reward.value:.3f}")
|
| 327 |
+
print(f"Breakdown: {reward.breakdown}")
|
| 328 |
+
print(f"Done: {done}")
|
| 329 |
+
|
| 330 |
+
# Run full episode
|
| 331 |
+
while not done:
|
| 332 |
+
action = Action(action_type="label_severity", severity="medium")
|
| 333 |
+
obs, reward, done, info = env.step(action)
|
| 334 |
+
|
| 335 |
+
# Export trajectory for MBRL research
|
| 336 |
+
trajectory = env.export_trajectory()
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
### HTTP API (OpenEnv Server)
|
| 340 |
+
|
| 341 |
+
```bash
|
| 342 |
+
# Start the server
|
| 343 |
+
uvicorn server.app:app --host 0.0.0.0 --port 7860
|
| 344 |
+
|
| 345 |
+
# Health check
|
| 346 |
+
curl http://localhost:7860/health
|
| 347 |
+
|
| 348 |
+
# Reset (start new episode)
|
| 349 |
+
curl -X POST http://localhost:7860/reset \
|
| 350 |
+
-H "Content-Type: application/json" \
|
| 351 |
+
-d '{"seed": 42}'
|
| 352 |
+
|
| 353 |
+
# Step (take action)
|
| 354 |
+
curl -X POST http://localhost:7860/step \
|
| 355 |
+
-H "Content-Type: application/json" \
|
| 356 |
+
-d '{"action": {"action_type": "label_severity", "severity": "high"}}'
|
| 357 |
+
|
| 358 |
+
# Get current state
|
| 359 |
+
curl http://localhost:7860/state
|
| 360 |
+
|
| 361 |
+
# Environment metadata
|
| 362 |
+
curl http://localhost:7860/metadata
|
| 363 |
+
|
| 364 |
+
# OpenAPI docs: http://localhost:7860/docs
|
| 365 |
+
```
|
| 366 |
+
|
| 367 |
+
### OpenEnv Client (Async/Sync)
|
| 368 |
+
|
| 369 |
+
```python
|
| 370 |
+
import asyncio
|
| 371 |
+
from code_review_env import CodeReviewEnv, CodeReviewAction
|
| 372 |
+
|
| 373 |
+
async def main():
|
| 374 |
+
async with CodeReviewEnv(base_url="https://openenv-code-review-env.hf.space") as env:
|
| 375 |
+
result = await env.reset(seed=42)
|
| 376 |
+
print(result.observation.pr_id)
|
| 377 |
+
|
| 378 |
+
result = await env.step(
|
| 379 |
+
CodeReviewAction(action_type="label_severity", severity="high")
|
| 380 |
+
)
|
| 381 |
+
print(f"Reward: {result.reward}, Done: {result.done}")
|
| 382 |
+
|
| 383 |
+
asyncio.run(main())
|
| 384 |
+
|
| 385 |
+
# Or synchronous:
|
| 386 |
+
with CodeReviewEnv(base_url="http://localhost:7860").sync() as env:
|
| 387 |
+
result = env.reset(seed=42)
|
| 388 |
+
result = env.step(CodeReviewAction(action_type="label_severity", severity="high"))
|
| 389 |
+
```
|
| 390 |
+
|
| 391 |
+
---
|
| 392 |
+
|
| 393 |
+
## Running Inference
|
| 394 |
+
|
| 395 |
+
The `inference.py` script is the mandatory evaluation entry point. It runs all 3 tasks, emits structured logs, and saves results.
|
| 396 |
+
|
| 397 |
+
### Environment Variables
|
| 398 |
+
|
| 399 |
+
These **must** be set before running inference:
|
| 400 |
+
|
| 401 |
+
| Variable | Required | Description |
|
| 402 |
+
|----------|----------|-------------|
|
| 403 |
+
| `API_BASE_URL` | Yes | The API endpoint for the LLM (default: `https://openrouter.ai/api/v1`) |
|
| 404 |
+
| `MODEL_NAME` | Yes | The model identifier (default: `openai/gpt-4o-mini`) |
|
| 405 |
+
| `HF_TOKEN` | Yes | Your Hugging Face / API key. Also accepts `OPENAI_API_KEY` or `API_KEY`. |
|
| 406 |
+
|
| 407 |
+
### Run
|
| 408 |
+
|
| 409 |
+
```bash
|
| 410 |
+
# Set required environment variables
|
| 411 |
+
export API_BASE_URL="https://openrouter.ai/api/v1"
|
| 412 |
+
export MODEL_NAME="openai/gpt-4o-mini"
|
| 413 |
+
export HF_TOKEN="your-api-key-here"
|
| 414 |
+
|
| 415 |
+
# Run inference (completes in < 20 minutes)
|
| 416 |
+
python inference.py
|
| 417 |
+
```
|
| 418 |
+
|
| 419 |
+
### What It Does
|
| 420 |
+
|
| 421 |
+
1. Initializes the OpenAI client with `API_BASE_URL` and `HF_TOKEN`
|
| 422 |
+
2. Runs 3 episodes per task (easy, medium, hard) with `seed=42`
|
| 423 |
+
3. Emits structured `[START]`, `[STEP]`, `[END]` logs to stdout
|
| 424 |
+
4. Saves results to `baseline/results.json`
|
| 425 |
+
|
| 426 |
+
---
|
| 427 |
+
|
| 428 |
+
## Structured Logging Format
|
| 429 |
+
|
| 430 |
+
The inference script emits structured stdout logs in the **mandatory** `[START]`, `[STEP]`, `[END]` format:
|
| 431 |
+
|
| 432 |
+
### `[START]` — emitted once per task
|
| 433 |
+
|
| 434 |
+
```
|
| 435 |
+
[START] {"task_id": "easy", "task_description": "Classify PR severity (none/low/medium/high/critical)"}
|
| 436 |
+
```
|
| 437 |
+
|
| 438 |
+
### `[STEP]` — emitted for each action taken
|
| 439 |
+
|
| 440 |
+
```
|
| 441 |
+
[STEP] {"step": 1, "action": "{\"action_type\": \"label_severity\", \"severity\": \"high\"}", "observation": "pr_id=PR-001, title=Fix null pointer in UserService.java, step=0", "reward": 0.5, "done": false}
|
| 442 |
+
```
|
| 443 |
+
|
| 444 |
+
### `[END]` — emitted once per task at completion
|
| 445 |
+
|
| 446 |
+
```
|
| 447 |
+
[END] {"task_id": "easy", "total_reward": 3.5, "steps": 5, "success": true}
|
| 448 |
+
```
|
| 449 |
+
|
| 450 |
+
---
|
| 451 |
+
|
| 452 |
+
## Pre-Submission Checklist
|
| 453 |
+
|
| 454 |
+
| Check | Command / Verification | Status |
|
| 455 |
+
|-------|----------------------|--------|
|
| 456 |
+
| HF Space deploys | `curl https://openenv-code-review-env.hf.space/health` returns 200 | ✅ |
|
| 457 |
+
| OpenEnv spec compliance | `python validate.py` — all 15 checks pass | ✅ |
|
| 458 |
+
| Dockerfile builds | `docker build -t code-review-env .` | ✅ |
|
| 459 |
+
| Baseline reproduces | `python baseline.py` completes end-to-end without errors | ✅ |
|
| 460 |
+
| LLM inference | `python inference.py` completes with API key, saves `baseline/results.json` | ✅ |
|
| 461 |
+
| 3+ tasks with graders | `easy`, `medium`, `hard` — all graders produce scores in `[0.0, 1.0]` | ✅ |
|
| 462 |
+
| Tests pass | `pytest tests/ -v` — 19 tests across 5 categories | ✅ |
|
| 463 |
+
| `API_BASE_URL` defined | Used by `inference.py` | ✅ |
|
| 464 |
+
| `MODEL_NAME` defined | Used by `inference.py` | ✅ |
|
| 465 |
+
| `HF_TOKEN` defined | Used by `inference.py` | ✅ |
|
| 466 |
+
| `inference.py` in root | Located at `./inference.py` | ✅ |
|
| 467 |
+
| Uses OpenAI Client | `from openai import OpenAI` | ✅ |
|
| 468 |
+
| Structured stdout logs | `[START]`, `[STEP]`, `[END]` format | ✅ |
|
| 469 |
+
| Runtime < 20 min | ~200 seconds | ✅ |
|
| 470 |
+
| Runs on vcpu=2, memory=8GB | No GPU dependencies, lightweight CPU inference | ✅ |
|
| 471 |
+
|
| 472 |
+
---
|
| 473 |
+
|
| 474 |
+
## Project Structure
|
| 475 |
+
|
| 476 |
+
```
|
| 477 |
+
code-review-env/
|
| 478 |
+
├── inference.py # Mandatory LLM evaluation script (root)
|
| 479 |
+
├── baseline.py # Heuristic baseline agent (no API key needed)
|
| 480 |
+
├── openenv.yaml # OpenEnv manifest
|
| 481 |
+
├── Dockerfile # Python 3.11 + uvicorn on :7860
|
| 482 |
+
├── requirements.txt # Core dependencies
|
| 483 |
+
├── requirements-research.txt # Optional: torch, sentence-transformers
|
| 484 |
+
├── pyproject.toml # Package configuration
|
| 485 |
+
├── validate.py # OpenEnv spec compliance validator
|
| 486 |
+
├── models.py # OpenEnv Action/Observation/State subclasses
|
| 487 |
+
├── client.py # CodeReviewEnv(EnvClient) — async/sync client
|
| 488 |
+
├── __init__.py # Package exports
|
| 489 |
+
│
|
| 490 |
+
├── env/ # Core environment logic
|
| 491 |
+
│ ├── base.py # CodeReviewEnv main class (S-MDP)
|
| 492 |
+
│ ├── models.py # Internal Pydantic models (Action, Observation, Reward, State)
|
| 493 |
+
│ ├── data_generator.py # 20 PR templates with real code diffs
|
| 494 |
+
│ └── trajectory_logger.py # JSONL trajectory logging for MBRL
|
| 495 |
+
│
|
| 496 |
+
├── server/ # OpenEnv-compliant server
|
| 497 |
+
│ ├── code_review_environment.py # Environment(OpenEnv base class)
|
| 498 |
+
│ └── app.py # create_app() — FastAPI + WebSocket
|
| 499 |
+
│
|
| 500 |
+
├── tasks/ # Three difficulty levels
|
| 501 |
+
│ ├── task_easy.py # Severity labeling (5 PRs/episode)
|
| 502 |
+
│ ├── task_medium.py # Queue prioritization (3 queues/episode)
|
| 503 |
+
│ └── task_hard.py # Feedback generation (3 PRs, multi-action)
|
| 504 |
+
│
|
| 505 |
+
├── graders/ # Deterministic graders
|
| 506 |
+
│ ├── grader_easy.py # Ordinal matching + critical penalties
|
| 507 |
+
│ ├── grader_medium.py # Kendall Tau + position penalties
|
| 508 |
+
│ ├── grader_hard.py # 5-component weighted scorer
|
| 509 |
+
│ └── reliability.py # Cohen's Kappa, Krippendorff's Alpha
|
| 510 |
+
│
|
| 511 |
+
├── benchmark/ # Baseline evaluation
|
| 512 |
+
│ ├── protocol.py # BenchmarkRunner, LaTeX tables
|
| 513 |
+
│ └── agents.py # RandomAgent, PerfectAgent
|
| 514 |
+
│
|
| 515 |
+
├── baseline/ # Saved results
|
| 516 |
+
│ └── results.json # GPT-4o-mini baseline scores
|
| 517 |
+
│
|
| 518 |
+
├── world_model/ # MBRL research scaffold
|
| 519 |
+
│ └── scaffold.py # SemanticTransitionDataset, WorldModelTrainer
|
| 520 |
+
│
|
| 521 |
+
└── tests/ # 19 tests across 5 categories
|
| 522 |
+
└── test_env.py # Core, grader, variance, reproducibility, exploit tests
|
| 523 |
+
```
|
| 524 |
+
|
| 525 |
+
---
|
| 526 |
+
|
| 527 |
+
## Trajectory Dataset
|
| 528 |
+
|
| 529 |
+
Each episode exports clean, structured trajectories for world model training:
|
| 530 |
+
|
| 531 |
+
```python
|
| 532 |
+
# Export from the environment
|
| 533 |
+
trajectory = env.export_trajectory()
|
| 534 |
+
|
| 535 |
+
# Each transition contains:
|
| 536 |
+
# {
|
| 537 |
+
# "step": 0,
|
| 538 |
+
# "state": { ... observation dict ... },
|
| 539 |
+
# "action": { "action_type": "label_severity", "severity": "high" },
|
| 540 |
+
# "reward": 0.6,
|
| 541 |
+
# "next_state": { ... next observation dict ... },
|
| 542 |
+
# "done": false,
|
| 543 |
+
# "timestamp": "...",
|
| 544 |
+
# "episode_id": "...",
|
| 545 |
+
# "task": "easy"
|
| 546 |
+
# }
|
| 547 |
+
```
|
| 548 |
+
|
| 549 |
+
**MBRL Research Application:** Encode states with sentence-transformers, train a transition model `f(z_t, a_t) → (z_{t+1}, r_t)`, then plan without the real environment — Dyna-Q over language state space. See `world_model/scaffold.py` for infrastructure.
|
| 550 |
+
|
| 551 |
+
---
|
| 552 |
+
|
| 553 |
+
## Citation
|
| 554 |
+
|
| 555 |
+
```bibtex
|
| 556 |
+
@misc{codereviewenv2026,
|
| 557 |
+
title={CodeReviewEnv: A Semantic RL Benchmark for Knowledge-Work Agents},
|
| 558 |
+
author={Raghav Rida},
|
| 559 |
+
year={2026},
|
| 560 |
+
note={OpenEnv Hackathon Submission}
|
| 561 |
+
}
|
| 562 |
+
```
|
| 563 |
+
|
| 564 |
+
---
|
| 565 |
+
|
| 566 |
+
## License
|
| 567 |
+
|
| 568 |
+
BSD-3-Clause
|
__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CodeReviewEnv — OpenEnv-compliant RL environment for software code review.
|
| 3 |
+
|
| 4 |
+
Public API:
|
| 5 |
+
from code_review_env import CodeReviewEnv, CodeReviewAction, CodeReviewObservation
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
# When installed as a package (pip install -e .)
|
| 10 |
+
from .models import CodeReviewAction, CodeReviewObservation, CodeReviewState
|
| 11 |
+
from .client import CodeReviewEnv
|
| 12 |
+
except ImportError:
|
| 13 |
+
# When running from project root (PYTHONPATH=.)
|
| 14 |
+
from models import CodeReviewAction, CodeReviewObservation, CodeReviewState
|
| 15 |
+
from client import CodeReviewEnv
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"CodeReviewEnv",
|
| 19 |
+
"CodeReviewAction",
|
| 20 |
+
"CodeReviewObservation",
|
| 21 |
+
"CodeReviewState",
|
| 22 |
+
]
|
analysis/__init__.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent Capability Profiler
|
| 3 |
+
|
| 4 |
+
Analyzes trajectory data to characterize agent behavior along
|
| 5 |
+
research-relevant dimensions. Enables comparative analysis
|
| 6 |
+
across different agent architectures.
|
| 7 |
+
|
| 8 |
+
Research motivation:
|
| 9 |
+
Standard benchmarks report only aggregate scores. This profiler
|
| 10 |
+
enables fine-grained behavioral analysis:
|
| 11 |
+
- Exploration rate: is the agent stuck in a policy rut?
|
| 12 |
+
- Reward trajectory shape: does the agent learn within episodes?
|
| 13 |
+
- Action distribution: does the agent exploit trivial strategies?
|
| 14 |
+
- Severity calibration: how well-calibrated are predictions?
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import hashlib
|
| 20 |
+
import statistics
|
| 21 |
+
from typing import Dict, List, Tuple, Optional
|
| 22 |
+
|
| 23 |
+
from env.data_generator import SEVERITY_ORDER
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class AgentProfiler:
|
| 27 |
+
"""
|
| 28 |
+
Analyzes trajectory data for research-grade agent characterization.
|
| 29 |
+
|
| 30 |
+
Use this to compare agent architectures beyond simple score tables.
|
| 31 |
+
Generate reports suitable for paper appendices.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def load_trajectories(self, directory: str) -> List[Dict]:
|
| 35 |
+
"""
|
| 36 |
+
Load all JSONL trajectory files from directory.
|
| 37 |
+
|
| 38 |
+
Each file is one episode, each line is one (s, a, r, s') transition.
|
| 39 |
+
"""
|
| 40 |
+
trajectories = []
|
| 41 |
+
if not os.path.exists(directory):
|
| 42 |
+
return trajectories
|
| 43 |
+
|
| 44 |
+
for filename in sorted(os.listdir(directory)):
|
| 45 |
+
if filename.endswith(".jsonl"):
|
| 46 |
+
filepath = os.path.join(directory, filename)
|
| 47 |
+
episode = []
|
| 48 |
+
with open(filepath, "r") as f:
|
| 49 |
+
for line in f:
|
| 50 |
+
line = line.strip()
|
| 51 |
+
if line:
|
| 52 |
+
episode.append(json.loads(line))
|
| 53 |
+
if episode:
|
| 54 |
+
trajectories.append(episode)
|
| 55 |
+
return trajectories
|
| 56 |
+
|
| 57 |
+
def compute_exploration_rate(self, trajectories: List[List[Dict]]) -> float:
|
| 58 |
+
"""
|
| 59 |
+
Fraction of unique (state_hash, action_type) pairs visited.
|
| 60 |
+
|
| 61 |
+
Low exploration = agent stuck in policy rut (always same action).
|
| 62 |
+
High exploration = agent adapts to different states.
|
| 63 |
+
|
| 64 |
+
State hash uses PR title + author_experience to avoid
|
| 65 |
+
hash collisions on diff content.
|
| 66 |
+
"""
|
| 67 |
+
if not trajectories:
|
| 68 |
+
return 0.0
|
| 69 |
+
|
| 70 |
+
unique_pairs = set()
|
| 71 |
+
total_steps = 0
|
| 72 |
+
|
| 73 |
+
for episode in trajectories:
|
| 74 |
+
for transition in episode:
|
| 75 |
+
state = transition.get("state", {})
|
| 76 |
+
action = transition.get("action", {})
|
| 77 |
+
|
| 78 |
+
# Hash state on key semantic features
|
| 79 |
+
state_key = f"{state.get('pr_id', '')}_{state.get('title', '')}"
|
| 80 |
+
state_hash = hashlib.md5(state_key.encode()).hexdigest()[:8]
|
| 81 |
+
|
| 82 |
+
action_type = action.get("action_type", "unknown")
|
| 83 |
+
pair = (state_hash, action_type)
|
| 84 |
+
unique_pairs.add(pair)
|
| 85 |
+
total_steps += 1
|
| 86 |
+
|
| 87 |
+
if total_steps == 0:
|
| 88 |
+
return 0.0
|
| 89 |
+
|
| 90 |
+
return len(unique_pairs) / total_steps
|
| 91 |
+
|
| 92 |
+
def compute_reward_trajectory_shape(self, trajectories: List[List[Dict]]) -> Dict:
|
| 93 |
+
"""
|
| 94 |
+
Analyze how reward evolves across steps within episodes.
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
slope: linear regression slope of reward over steps
|
| 98 |
+
variance: reward variance within episodes
|
| 99 |
+
monotonic_fraction: fraction of episodes with monotonically
|
| 100 |
+
increasing reward (in-context learning signal)
|
| 101 |
+
"""
|
| 102 |
+
if not trajectories:
|
| 103 |
+
return {"slope": 0.0, "variance": 0.0, "monotonic_fraction": 0.0}
|
| 104 |
+
|
| 105 |
+
all_slopes = []
|
| 106 |
+
all_variances = []
|
| 107 |
+
monotonic_count = 0
|
| 108 |
+
|
| 109 |
+
for episode in trajectories:
|
| 110 |
+
rewards = [t.get("reward", {}).get("value", 0.0) for t in episode]
|
| 111 |
+
if len(rewards) < 2:
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
# Simple linear regression slope
|
| 115 |
+
n = len(rewards)
|
| 116 |
+
x_mean = (n - 1) / 2
|
| 117 |
+
y_mean = sum(rewards) / n
|
| 118 |
+
numerator = sum((i - x_mean) * (r - y_mean) for i, r in enumerate(rewards))
|
| 119 |
+
denominator = sum((i - x_mean) ** 2 for i in range(n))
|
| 120 |
+
slope = numerator / denominator if denominator != 0 else 0.0
|
| 121 |
+
all_slopes.append(slope)
|
| 122 |
+
|
| 123 |
+
# Variance
|
| 124 |
+
if len(rewards) > 1:
|
| 125 |
+
all_variances.append(statistics.variance(rewards))
|
| 126 |
+
|
| 127 |
+
# Monotonicity check
|
| 128 |
+
is_monotonic = all(rewards[i] <= rewards[i + 1] for i in range(len(rewards) - 1))
|
| 129 |
+
if is_monotonic:
|
| 130 |
+
monotonic_count += 1
|
| 131 |
+
|
| 132 |
+
n_episodes = len(trajectories)
|
| 133 |
+
return {
|
| 134 |
+
"slope": statistics.mean(all_slopes) if all_slopes else 0.0,
|
| 135 |
+
"variance": statistics.mean(all_variances) if all_variances else 0.0,
|
| 136 |
+
"monotonic_fraction": monotonic_count / n_episodes if n_episodes > 0 else 0.0,
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
def compute_action_distribution(self, trajectories: List[List[Dict]]) -> Dict:
|
| 140 |
+
"""
|
| 141 |
+
Distribution of action_types across all steps.
|
| 142 |
+
|
| 143 |
+
Reveals if agent exploits (e.g., always approve) or uses
|
| 144 |
+
the full action space. Uniform distribution over valid
|
| 145 |
+
actions suggests genuine exploration.
|
| 146 |
+
"""
|
| 147 |
+
counts: Dict[str, int] = {}
|
| 148 |
+
total = 0
|
| 149 |
+
|
| 150 |
+
for episode in trajectories:
|
| 151 |
+
for transition in episode:
|
| 152 |
+
action = transition.get("action", {})
|
| 153 |
+
action_type = action.get("action_type", "unknown")
|
| 154 |
+
counts[action_type] = counts.get(action_type, 0) + 1
|
| 155 |
+
total += 1
|
| 156 |
+
|
| 157 |
+
if total == 0:
|
| 158 |
+
return {}
|
| 159 |
+
|
| 160 |
+
return {k: {"count": v, "fraction": v / total} for k, v in sorted(counts.items())}
|
| 161 |
+
|
| 162 |
+
def compute_severity_calibration(self, trajectories: List[List[Dict]]) -> Dict:
|
| 163 |
+
"""
|
| 164 |
+
For easy task: calibration curve of predicted vs true severity.
|
| 165 |
+
|
| 166 |
+
Similar to probability calibration in classification literature.
|
| 167 |
+
Returns fraction of correct predictions per severity level.
|
| 168 |
+
"""
|
| 169 |
+
correct_by_severity: Dict[str, int] = {s: 0 for s in SEVERITY_ORDER}
|
| 170 |
+
total_by_severity: Dict[str, int] = {s: 0 for s in SEVERITY_ORDER}
|
| 171 |
+
|
| 172 |
+
for episode in trajectories:
|
| 173 |
+
for transition in episode:
|
| 174 |
+
action = transition.get("action", {})
|
| 175 |
+
if action.get("action_type") != "label_severity":
|
| 176 |
+
continue
|
| 177 |
+
|
| 178 |
+
predicted = action.get("severity", "none")
|
| 179 |
+
# Get true severity from reward reason
|
| 180 |
+
reason = transition.get("reward", {}).get("reason", "")
|
| 181 |
+
true_sev = None
|
| 182 |
+
if "Truth:" in reason:
|
| 183 |
+
parts = reason.split("Truth:")
|
| 184 |
+
if len(parts) > 1:
|
| 185 |
+
true_sev = parts[1].strip().split()[0].strip(",")
|
| 186 |
+
|
| 187 |
+
if true_sev and true_sev in total_by_severity:
|
| 188 |
+
total_by_severity[true_sev] += 1
|
| 189 |
+
if predicted == true_sev:
|
| 190 |
+
correct_by_severity[true_sev] += 1
|
| 191 |
+
|
| 192 |
+
calibration = {}
|
| 193 |
+
for sev in SEVERITY_ORDER:
|
| 194 |
+
total = total_by_severity[sev]
|
| 195 |
+
if total > 0:
|
| 196 |
+
calibration[sev] = {
|
| 197 |
+
"accuracy": correct_by_severity[sev] / total,
|
| 198 |
+
"total": total,
|
| 199 |
+
"correct": correct_by_severity[sev],
|
| 200 |
+
}
|
| 201 |
+
else:
|
| 202 |
+
calibration[sev] = {"accuracy": 0.0, "total": 0, "correct": 0}
|
| 203 |
+
|
| 204 |
+
return calibration
|
| 205 |
+
|
| 206 |
+
def compare_agents(self, agent_a_dir: str, agent_b_dir: str) -> Dict:
|
| 207 |
+
"""
|
| 208 |
+
Statistical comparison between two agents.
|
| 209 |
+
|
| 210 |
+
Uses Mann-Whitney U test for non-parametric comparison
|
| 211 |
+
and Cohen's d for effect size measurement.
|
| 212 |
+
"""
|
| 213 |
+
traj_a = self.load_trajectories(agent_a_dir)
|
| 214 |
+
traj_b = self.load_trajectories(agent_b_dir)
|
| 215 |
+
|
| 216 |
+
scores_a = [
|
| 217 |
+
statistics.mean([t.get("reward", {}).get("value", 0.0) for t in ep])
|
| 218 |
+
for ep in traj_a if ep
|
| 219 |
+
]
|
| 220 |
+
scores_b = [
|
| 221 |
+
statistics.mean([t.get("reward", {}).get("value", 0.0) for t in ep])
|
| 222 |
+
for ep in traj_b if ep
|
| 223 |
+
]
|
| 224 |
+
|
| 225 |
+
if not scores_a or not scores_b:
|
| 226 |
+
return {"error": "Insufficient trajectory data for comparison"}
|
| 227 |
+
|
| 228 |
+
mean_a = statistics.mean(scores_a)
|
| 229 |
+
mean_b = statistics.mean(scores_b)
|
| 230 |
+
std_a = statistics.stdev(scores_a) if len(scores_a) > 1 else 0.001
|
| 231 |
+
std_b = statistics.stdev(scores_b) if len(scores_b) > 1 else 0.001
|
| 232 |
+
|
| 233 |
+
# Cohen's d effect size
|
| 234 |
+
pooled_std = ((std_a ** 2 + std_b ** 2) / 2) ** 0.5
|
| 235 |
+
cohens_d = (mean_a - mean_b) / pooled_std if pooled_std > 0 else 0.0
|
| 236 |
+
|
| 237 |
+
# Mann-Whitney U (simplified — counts wins)
|
| 238 |
+
u_stat = 0
|
| 239 |
+
for sa in scores_a:
|
| 240 |
+
for sb in scores_b:
|
| 241 |
+
if sa > sb:
|
| 242 |
+
u_stat += 1
|
| 243 |
+
elif sa == sb:
|
| 244 |
+
u_stat += 0.5
|
| 245 |
+
|
| 246 |
+
n_a, n_b = len(scores_a), len(scores_b)
|
| 247 |
+
max_u = n_a * n_b
|
| 248 |
+
|
| 249 |
+
return {
|
| 250 |
+
"agent_a": {"mean": mean_a, "std": std_a, "n": n_a},
|
| 251 |
+
"agent_b": {"mean": mean_b, "std": std_b, "n": n_b},
|
| 252 |
+
"cohens_d": cohens_d,
|
| 253 |
+
"effect_interpretation": self._interpret_effect(abs(cohens_d)),
|
| 254 |
+
"mann_whitney_u": u_stat,
|
| 255 |
+
"u_normalized": u_stat / max_u if max_u > 0 else 0.0,
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
@staticmethod
|
| 259 |
+
def _interpret_effect(d: float) -> str:
|
| 260 |
+
"""Interpret Cohen's d effect size (Cohen, 1988)."""
|
| 261 |
+
if d < 0.2:
|
| 262 |
+
return "negligible"
|
| 263 |
+
elif d < 0.5:
|
| 264 |
+
return "small"
|
| 265 |
+
elif d < 0.8:
|
| 266 |
+
return "medium"
|
| 267 |
+
else:
|
| 268 |
+
return "large"
|
| 269 |
+
|
| 270 |
+
def generate_report(self, trajectories: List[List[Dict]], agent_name: str) -> str:
|
| 271 |
+
"""
|
| 272 |
+
Generate markdown report with all metrics.
|
| 273 |
+
|
| 274 |
+
Format suitable for inclusion in paper appendix.
|
| 275 |
+
"""
|
| 276 |
+
exploration = self.compute_exploration_rate(trajectories)
|
| 277 |
+
shape = self.compute_reward_trajectory_shape(trajectories)
|
| 278 |
+
actions = self.compute_action_distribution(trajectories)
|
| 279 |
+
calibration = self.compute_severity_calibration(trajectories)
|
| 280 |
+
|
| 281 |
+
# Compute aggregate scores
|
| 282 |
+
episode_scores = []
|
| 283 |
+
for ep in trajectories:
|
| 284 |
+
if ep:
|
| 285 |
+
rewards = [t.get("reward", {}).get("value", 0.0) for t in ep]
|
| 286 |
+
episode_scores.append(statistics.mean(rewards))
|
| 287 |
+
|
| 288 |
+
report = f"# Agent Profile: {agent_name}\n\n"
|
| 289 |
+
report += f"## Summary\n"
|
| 290 |
+
report += f"- Episodes: {len(trajectories)}\n"
|
| 291 |
+
if episode_scores:
|
| 292 |
+
report += f"- Mean score: {statistics.mean(episode_scores):.3f}\n"
|
| 293 |
+
if len(episode_scores) > 1:
|
| 294 |
+
report += f"- Score std: {statistics.stdev(episode_scores):.3f}\n"
|
| 295 |
+
report += f"- Exploration rate: {exploration:.3f}\n"
|
| 296 |
+
report += f"- Reward slope: {shape['slope']:.4f}\n"
|
| 297 |
+
report += f"- Monotonic episodes: {shape['monotonic_fraction']:.1%}\n\n"
|
| 298 |
+
|
| 299 |
+
report += f"## Action Distribution\n"
|
| 300 |
+
report += "| Action | Count | Fraction |\n"
|
| 301 |
+
report += "|--------|-------|----------|\n"
|
| 302 |
+
for atype, info in actions.items():
|
| 303 |
+
report += f"| {atype} | {info['count']} | {info['fraction']:.2%} |\n"
|
| 304 |
+
report += "\n"
|
| 305 |
+
|
| 306 |
+
report += f"## Severity Calibration\n"
|
| 307 |
+
report += "| Severity | Accuracy | Total | Correct |\n"
|
| 308 |
+
report += "|----------|----------|-------|---------|\n"
|
| 309 |
+
for sev, info in calibration.items():
|
| 310 |
+
report += f"| {sev} | {info['accuracy']:.2%} | {info['total']} | {info['correct']} |\n"
|
| 311 |
+
|
| 312 |
+
return report
|
analysis/agent_profiler.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent Capability Profiler — see analysis/__init__.py for documentation.
|
| 3 |
+
|
| 4 |
+
This module re-exports from the package init for conventional import patterns.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from analysis import AgentProfiler
|
| 8 |
+
|
| 9 |
+
__all__ = ["AgentProfiler"]
|
baseline.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
CodeReviewEnv — Baseline Agent Script
|
| 4 |
+
======================================
|
| 5 |
+
Runs a simple heuristic agent (no LLM) against all three tasks
|
| 6 |
+
and reports per-task scores. This verifies the environment works
|
| 7 |
+
end-to-end without requiring any API keys.
|
| 8 |
+
|
| 9 |
+
For LLM-based evaluation, use inference.py instead.
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python baseline.py
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import statistics
|
| 19 |
+
import time
|
| 20 |
+
from typing import Dict, List, Tuple
|
| 21 |
+
|
| 22 |
+
from env.base import CodeReviewEnv
|
| 23 |
+
from env.models import Action
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ─── Heuristic Agent ────────────────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
def heuristic_easy_action(obs) -> Action:
|
| 29 |
+
"""Simple heuristic: guess severity based on keywords in diff."""
|
| 30 |
+
diff_text = ""
|
| 31 |
+
for f in obs.files:
|
| 32 |
+
diff_text += f.diff.lower()
|
| 33 |
+
|
| 34 |
+
if any(kw in diff_text for kw in ["injection", "secret", "hardcoded", "plaintext", "md5"]):
|
| 35 |
+
severity = "critical"
|
| 36 |
+
elif any(kw in diff_text for kw in ["null", "none", "nil", "race", "mutex", "lock"]):
|
| 37 |
+
severity = "high"
|
| 38 |
+
elif any(kw in diff_text for kw in ["bug", "error", "exception", "off-by-one", "boundary"]):
|
| 39 |
+
severity = "medium"
|
| 40 |
+
elif any(kw in diff_text for kw in ["o(n)", "performance", "loop", "cache", "index"]):
|
| 41 |
+
severity = "low"
|
| 42 |
+
else:
|
| 43 |
+
severity = "none"
|
| 44 |
+
|
| 45 |
+
return Action(action_type="label_severity", severity=severity)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def heuristic_medium_action(obs) -> Action:
|
| 49 |
+
"""Simple heuristic: return queue in original order (no reordering)."""
|
| 50 |
+
return Action(action_type="prioritize", priority_order=list(obs.review_queue))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def heuristic_hard_action(obs, step_in_pr: int) -> Action:
|
| 54 |
+
"""Simple heuristic: add one generic comment then request_changes."""
|
| 55 |
+
if step_in_pr == 0 and obs.files:
|
| 56 |
+
f = obs.files[0]
|
| 57 |
+
return Action(
|
| 58 |
+
action_type="add_comment",
|
| 59 |
+
comment="Consider reviewing this section for potential issues.",
|
| 60 |
+
target_file=f.filename,
|
| 61 |
+
target_line=10,
|
| 62 |
+
)
|
| 63 |
+
return Action(action_type="request_changes")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ─── Task Runners ───────────────────────────────────────────────────────────
|
| 67 |
+
|
| 68 |
+
def run_easy_episode(seed: int) -> Tuple[float, int]:
|
| 69 |
+
"""Run one easy episode. Returns (mean_reward, steps)."""
|
| 70 |
+
env = CodeReviewEnv(task="easy", seed=seed)
|
| 71 |
+
obs = env.reset()
|
| 72 |
+
rewards = []
|
| 73 |
+
done = False
|
| 74 |
+
|
| 75 |
+
while not done:
|
| 76 |
+
action = heuristic_easy_action(obs)
|
| 77 |
+
obs, reward, done, info = env.step(action)
|
| 78 |
+
rewards.append(reward.value)
|
| 79 |
+
|
| 80 |
+
mean = statistics.mean(rewards) if rewards else 0.0
|
| 81 |
+
return mean, len(rewards)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def run_medium_episode(seed: int) -> Tuple[float, int]:
|
| 85 |
+
"""Run one medium episode. Returns (mean_reward, steps)."""
|
| 86 |
+
env = CodeReviewEnv(task="medium", seed=seed)
|
| 87 |
+
obs = env.reset()
|
| 88 |
+
rewards = []
|
| 89 |
+
done = False
|
| 90 |
+
|
| 91 |
+
while not done:
|
| 92 |
+
action = heuristic_medium_action(obs)
|
| 93 |
+
obs, reward, done, info = env.step(action)
|
| 94 |
+
rewards.append(reward.value)
|
| 95 |
+
|
| 96 |
+
mean = statistics.mean(rewards) if rewards else 0.0
|
| 97 |
+
return mean, len(rewards)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def run_hard_episode(seed: int) -> Tuple[float, int]:
|
| 101 |
+
"""Run one hard episode. Returns (mean_reward, steps)."""
|
| 102 |
+
env = CodeReviewEnv(task="hard", seed=seed)
|
| 103 |
+
obs = env.reset()
|
| 104 |
+
rewards = []
|
| 105 |
+
done = False
|
| 106 |
+
step_in_pr = 0
|
| 107 |
+
|
| 108 |
+
while not done:
|
| 109 |
+
action = heuristic_hard_action(obs, step_in_pr)
|
| 110 |
+
obs, reward, done, info = env.step(action)
|
| 111 |
+
rewards.append(reward.value)
|
| 112 |
+
|
| 113 |
+
if action.action_type in ("approve", "request_changes"):
|
| 114 |
+
step_in_pr = 0
|
| 115 |
+
else:
|
| 116 |
+
step_in_pr += 1
|
| 117 |
+
|
| 118 |
+
# Filter out comment acks for PR-level scoring
|
| 119 |
+
pr_rewards = [r for r in rewards if abs(r - 0.05) > 0.01]
|
| 120 |
+
mean = statistics.mean(pr_rewards) if pr_rewards else 0.0
|
| 121 |
+
return mean, len(rewards)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ─── Main ────────────────────────────────────────────────────────────────────
|
| 125 |
+
|
| 126 |
+
def main():
|
| 127 |
+
SEED = 42
|
| 128 |
+
N_EPISODES = 3
|
| 129 |
+
|
| 130 |
+
print("=" * 60)
|
| 131 |
+
print("CodeReviewEnv — Baseline Heuristic Agent")
|
| 132 |
+
print("=" * 60)
|
| 133 |
+
|
| 134 |
+
start_time = time.time()
|
| 135 |
+
all_results: Dict[str, Dict] = {}
|
| 136 |
+
|
| 137 |
+
for task_name, runner in [("easy", run_easy_episode), ("medium", run_medium_episode), ("hard", run_hard_episode)]:
|
| 138 |
+
print(f"\n--- {task_name.upper()} Task ---")
|
| 139 |
+
scores = []
|
| 140 |
+
|
| 141 |
+
for ep in range(N_EPISODES):
|
| 142 |
+
ep_seed = SEED + ep
|
| 143 |
+
score, steps = runner(ep_seed)
|
| 144 |
+
scores.append(score)
|
| 145 |
+
print(f" Episode {ep + 1}: score={score:.4f} ({steps} steps)")
|
| 146 |
+
|
| 147 |
+
mean = statistics.mean(scores)
|
| 148 |
+
std = statistics.stdev(scores) if len(scores) > 1 else 0.0
|
| 149 |
+
all_results[task_name] = {
|
| 150 |
+
"mean": round(mean, 4),
|
| 151 |
+
"std": round(std, 4),
|
| 152 |
+
"scores": [round(s, 4) for s in scores],
|
| 153 |
+
}
|
| 154 |
+
print(f" → Mean: {mean:.4f} ± {std:.4f}")
|
| 155 |
+
|
| 156 |
+
elapsed = time.time() - start_time
|
| 157 |
+
composite = round(
|
| 158 |
+
sum(r["mean"] for r in all_results.values()) / len(all_results), 4
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# ── Summary Table ────────────────────────────────────────────────
|
| 162 |
+
print(f"\n{'=' * 60}")
|
| 163 |
+
print(f"{'Task':<10} | {'Mean':>8} | {'Std':>8} | {'Scores'}")
|
| 164 |
+
print(f"{'-' * 10}-+-{'-' * 8}-+-{'-' * 8}-+-{'-' * 20}")
|
| 165 |
+
for task in ["easy", "medium", "hard"]:
|
| 166 |
+
r = all_results[task]
|
| 167 |
+
scores_str = ", ".join(f"{s:.3f}" for s in r["scores"])
|
| 168 |
+
print(f"{task:<10} | {r['mean']:>8.4f} | {r['std']:>8.4f} | [{scores_str}]")
|
| 169 |
+
print(f"{'=' * 60}")
|
| 170 |
+
print(f"Composite Score: {composite:.4f}")
|
| 171 |
+
print(f"Elapsed: {elapsed:.1f}s")
|
| 172 |
+
|
| 173 |
+
# ── Save Results ─────────────────────────────────────────────────
|
| 174 |
+
output = {
|
| 175 |
+
"agent": "heuristic_baseline",
|
| 176 |
+
"composite": composite,
|
| 177 |
+
"seed": SEED,
|
| 178 |
+
"episodes_per_task": N_EPISODES,
|
| 179 |
+
**all_results,
|
| 180 |
+
"elapsed_seconds": round(elapsed, 1),
|
| 181 |
+
}
|
| 182 |
+
results_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "baseline", "heuristic_results.json")
|
| 183 |
+
os.makedirs(os.path.dirname(results_path), exist_ok=True)
|
| 184 |
+
with open(results_path, "w") as f:
|
| 185 |
+
json.dump(output, f, indent=2)
|
| 186 |
+
print(f"\nResults saved to {results_path}")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
if __name__ == "__main__":
|
| 190 |
+
main()
|
baseline/heuristic_results.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"agent": "heuristic_baseline",
|
| 3 |
+
"composite": 0.6352,
|
| 4 |
+
"seed": 42,
|
| 5 |
+
"episodes_per_task": 3,
|
| 6 |
+
"easy": {
|
| 7 |
+
"mean": 0.8,
|
| 8 |
+
"std": 0.2646,
|
| 9 |
+
"scores": [
|
| 10 |
+
0.5,
|
| 11 |
+
1.0,
|
| 12 |
+
0.9
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
"medium": {
|
| 16 |
+
"mean": 0.4111,
|
| 17 |
+
"std": 0.0839,
|
| 18 |
+
"scores": [
|
| 19 |
+
0.4,
|
| 20 |
+
0.3333,
|
| 21 |
+
0.5
|
| 22 |
+
]
|
| 23 |
+
},
|
| 24 |
+
"hard": {
|
| 25 |
+
"mean": 0.6944,
|
| 26 |
+
"std": 0.1005,
|
| 27 |
+
"scores": [
|
| 28 |
+
0.6833,
|
| 29 |
+
0.6,
|
| 30 |
+
0.8
|
| 31 |
+
]
|
| 32 |
+
},
|
| 33 |
+
"elapsed_seconds": 0.0
|
| 34 |
+
}
|
baseline/live_results.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model": "openai/gpt-4o-mini",
|
| 3 |
+
"space_url": "https://ragavrida-code-review-env.hf.space",
|
| 4 |
+
"seed": 42,
|
| 5 |
+
"episodes": 2,
|
| 6 |
+
"tasks": {
|
| 7 |
+
"easy": {
|
| 8 |
+
"mean": 0.8500000000000001,
|
| 9 |
+
"std": 0.07071067811865474,
|
| 10 |
+
"scores": [
|
| 11 |
+
0.8,
|
| 12 |
+
0.9
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
"medium": {
|
| 16 |
+
"mean": 0.3666666666666667,
|
| 17 |
+
"std": 3.925231146709438e-17,
|
| 18 |
+
"scores": [
|
| 19 |
+
0.36666666666666664,
|
| 20 |
+
0.3666666666666667
|
| 21 |
+
]
|
| 22 |
+
},
|
| 23 |
+
"hard": {
|
| 24 |
+
"mean": 0.7749999999999999,
|
| 25 |
+
"std": 0.05892556509887899,
|
| 26 |
+
"scores": [
|
| 27 |
+
0.8166666666666667,
|
| 28 |
+
0.7333333333333333
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
},
|
| 32 |
+
"composite": 0.6638888888888889,
|
| 33 |
+
"elapsed_s": 174.4,
|
| 34 |
+
"timestamp": "2026-04-03T08:00:10Z"
|
| 35 |
+
}
|
baseline/results.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_note": "Placeholder results. Run 'python inference.py' with API_BASE_URL, MODEL_NAME, and HF_TOKEN set to generate real LLM-based scores.",
|
| 3 |
+
"model": "openai/gpt-4o-mini",
|
| 4 |
+
"composite": null,
|
| 5 |
+
"seed": 42,
|
| 6 |
+
"easy": {
|
| 7 |
+
"mean": null,
|
| 8 |
+
"std": null,
|
| 9 |
+
"scores": []
|
| 10 |
+
},
|
| 11 |
+
"medium": {
|
| 12 |
+
"mean": null,
|
| 13 |
+
"std": null,
|
| 14 |
+
"scores": []
|
| 15 |
+
},
|
| 16 |
+
"hard": {
|
| 17 |
+
"mean": null,
|
| 18 |
+
"std": null,
|
| 19 |
+
"scores": []
|
| 20 |
+
},
|
| 21 |
+
"elapsed_seconds": null
|
| 22 |
+
}
|
baseline/run_baseline.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Baseline Agent — GPT-4o-mini on CodeReviewEnv
|
| 4 |
+
|
| 5 |
+
Runs the GPT-4o-mini model against all three tasks and records scores.
|
| 6 |
+
Requires OPENAI_API_KEY environment variable.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
OPENAI_API_KEY=sk-... python baseline/run_baseline.py
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
import statistics
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
+
|
| 18 |
+
# Add parent directory to path
|
| 19 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 20 |
+
|
| 21 |
+
from env.base import CodeReviewEnv
|
| 22 |
+
from env.models import Action
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def run_baseline():
|
| 26 |
+
"""Run GPT-4o-mini baseline across all tasks via OpenRouter."""
|
| 27 |
+
api_key = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
| 28 |
+
if not api_key:
|
| 29 |
+
print("ERROR: OPENROUTER_API_KEY or OPENAI_API_KEY not set.")
|
| 30 |
+
print("Usage: OPENROUTER_API_KEY=sk-... python baseline/run_baseline.py")
|
| 31 |
+
sys.exit(1)
|
| 32 |
+
|
| 33 |
+
from openai import OpenAI
|
| 34 |
+
client = OpenAI(
|
| 35 |
+
api_key=api_key,
|
| 36 |
+
base_url="https://openrouter.ai/api/v1",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
model = "openai/gpt-4o-mini"
|
| 40 |
+
seed = 42
|
| 41 |
+
n_episodes = 3
|
| 42 |
+
|
| 43 |
+
results = {}
|
| 44 |
+
|
| 45 |
+
for task in ["easy", "medium", "hard"]:
|
| 46 |
+
print(f"\n{'='*40}")
|
| 47 |
+
print(f"Running {task} task — {n_episodes} episodes")
|
| 48 |
+
print(f"{'='*40}")
|
| 49 |
+
|
| 50 |
+
episode_scores = []
|
| 51 |
+
|
| 52 |
+
for ep in range(n_episodes):
|
| 53 |
+
episode_seed = seed + ep
|
| 54 |
+
env = CodeReviewEnv(task=task, seed=episode_seed)
|
| 55 |
+
obs = env.reset()
|
| 56 |
+
system_prompt = env.get_system_prompt()
|
| 57 |
+
|
| 58 |
+
step_rewards = []
|
| 59 |
+
done = False
|
| 60 |
+
max_steps = 50
|
| 61 |
+
|
| 62 |
+
while not done and len(step_rewards) < max_steps:
|
| 63 |
+
# Build user message from observation
|
| 64 |
+
user_msg = json.dumps(obs.model_dump(), indent=2, default=str)
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
response = client.chat.completions.create(
|
| 68 |
+
model=model,
|
| 69 |
+
messages=[
|
| 70 |
+
{"role": "system", "content": system_prompt},
|
| 71 |
+
{"role": "user", "content": user_msg},
|
| 72 |
+
],
|
| 73 |
+
temperature=0,
|
| 74 |
+
seed=seed,
|
| 75 |
+
max_tokens=500,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
response_text = response.choices[0].message.content.strip()
|
| 79 |
+
|
| 80 |
+
# Try to parse JSON — handle common LLM output quirks
|
| 81 |
+
# 1. Strip markdown code blocks
|
| 82 |
+
if "```" in response_text:
|
| 83 |
+
import re
|
| 84 |
+
code_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?\s*```', response_text, re.DOTALL)
|
| 85 |
+
if code_match:
|
| 86 |
+
response_text = code_match.group(1).strip()
|
| 87 |
+
|
| 88 |
+
# 2. Extract first JSON object (ignore trailing explanation text)
|
| 89 |
+
brace_start = response_text.find("{")
|
| 90 |
+
if brace_start >= 0:
|
| 91 |
+
depth = 0
|
| 92 |
+
for i, ch in enumerate(response_text[brace_start:], start=brace_start):
|
| 93 |
+
if ch == "{":
|
| 94 |
+
depth += 1
|
| 95 |
+
elif ch == "}":
|
| 96 |
+
depth -= 1
|
| 97 |
+
if depth == 0:
|
| 98 |
+
response_text = response_text[brace_start:i+1]
|
| 99 |
+
break
|
| 100 |
+
|
| 101 |
+
action_dict = json.loads(response_text)
|
| 102 |
+
action = Action(**action_dict)
|
| 103 |
+
|
| 104 |
+
except Exception as e:
|
| 105 |
+
print(f" Parse error at step {len(step_rewards)}: {e}")
|
| 106 |
+
# Fallback action
|
| 107 |
+
if task == "easy":
|
| 108 |
+
action = Action(action_type="label_severity", severity="none")
|
| 109 |
+
elif task == "medium":
|
| 110 |
+
action = Action(
|
| 111 |
+
action_type="prioritize",
|
| 112 |
+
priority_order=obs.review_queue,
|
| 113 |
+
)
|
| 114 |
+
else:
|
| 115 |
+
action = Action(action_type="approve")
|
| 116 |
+
|
| 117 |
+
obs, reward, done, info = env.step(action)
|
| 118 |
+
step_rewards.append(reward.value)
|
| 119 |
+
|
| 120 |
+
ep_score = sum(step_rewards) / len(step_rewards) if step_rewards else 0.0
|
| 121 |
+
episode_scores.append(round(ep_score, 2))
|
| 122 |
+
print(f" Episode {ep + 1}: score={ep_score:.3f} ({len(step_rewards)} steps)")
|
| 123 |
+
|
| 124 |
+
mean_score = statistics.mean(episode_scores)
|
| 125 |
+
std_score = statistics.stdev(episode_scores) if len(episode_scores) > 1 else 0.0
|
| 126 |
+
|
| 127 |
+
results[task] = {
|
| 128 |
+
"mean": round(mean_score, 2),
|
| 129 |
+
"std": round(std_score, 2),
|
| 130 |
+
"episodes": episode_scores,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
print(f"\n {task}: mean={mean_score:.3f} std={std_score:.3f}")
|
| 134 |
+
|
| 135 |
+
# Print summary table
|
| 136 |
+
print(f"\n{'='*60}")
|
| 137 |
+
print(f"{'Task':<10} | {'Episodes':>8} | {'Mean':>6} | {'Std':>6} | {'Min':>6} | {'Max':>6}")
|
| 138 |
+
print(f"{'-'*10}-+-{'-'*8}-+-{'-'*6}-+-{'-'*6}-+-{'-'*6}-+-{'-'*6}")
|
| 139 |
+
for task in ["easy", "medium", "hard"]:
|
| 140 |
+
r = results[task]
|
| 141 |
+
eps = r["episodes"]
|
| 142 |
+
print(
|
| 143 |
+
f"{task:<10} | {len(eps):>8} | {r['mean']:>6.2f} | {r['std']:>6.2f} | "
|
| 144 |
+
f"{min(eps):>6.2f} | {max(eps):>6.2f}"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# Save results
|
| 148 |
+
output = {
|
| 149 |
+
**results,
|
| 150 |
+
"model": model,
|
| 151 |
+
"seed": seed,
|
| 152 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
output_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results.json")
|
| 156 |
+
with open(output_path, "w") as f:
|
| 157 |
+
json.dump(output, f, indent=2)
|
| 158 |
+
|
| 159 |
+
print(f"\nResults saved to {output_path}")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
if __name__ == "__main__":
|
| 163 |
+
run_baseline()
|
benchmark/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Benchmark package — standardized evaluation protocol and baseline agents."""
|
benchmark/agents.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Benchmark Agents — Random and Perfect baselines.
|
| 3 |
+
|
| 4 |
+
These establish the floor and ceiling of the CodeReviewEnv benchmark.
|
| 5 |
+
Every new agent should be compared against these two.
|
| 6 |
+
|
| 7 |
+
RandomAgent: picks actions uniformly at random (floor)
|
| 8 |
+
PerfectAgent: reads ground truth, always correct (ceiling)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
from typing import Dict, List
|
| 13 |
+
|
| 14 |
+
from env.data_generator import PR_TEMPLATES, get_ground_truth, DataGenerator, SEVERITY_ORDER
|
| 15 |
+
from env.models import Action
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class RandomAgent:
|
| 19 |
+
"""
|
| 20 |
+
Picks action_type uniformly at random, random severity.
|
| 21 |
+
|
| 22 |
+
Establishes the benchmark floor — any useful agent must
|
| 23 |
+
significantly outperform random. Expected composite score ~0.18.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, seed: int = 42):
|
| 27 |
+
self.rng = random.Random(seed)
|
| 28 |
+
|
| 29 |
+
def act(self, observation: Dict, system_prompt: str) -> Dict:
|
| 30 |
+
"""
|
| 31 |
+
Generate random action based on system prompt content.
|
| 32 |
+
|
| 33 |
+
Detects task from system prompt to generate valid action types.
|
| 34 |
+
"""
|
| 35 |
+
if "label_severity" in system_prompt:
|
| 36 |
+
return self._act_easy(observation)
|
| 37 |
+
elif "prioritize" in system_prompt:
|
| 38 |
+
return self._act_medium(observation)
|
| 39 |
+
else:
|
| 40 |
+
return self._act_hard(observation)
|
| 41 |
+
|
| 42 |
+
def _act_easy(self, observation: Dict) -> Dict:
|
| 43 |
+
"""Random severity label."""
|
| 44 |
+
severity = self.rng.choice(SEVERITY_ORDER)
|
| 45 |
+
return {"action_type": "label_severity", "severity": severity}
|
| 46 |
+
|
| 47 |
+
def _act_medium(self, observation: Dict) -> Dict:
|
| 48 |
+
"""Random queue ordering."""
|
| 49 |
+
queue = list(observation.get("review_queue", []))
|
| 50 |
+
self.rng.shuffle(queue)
|
| 51 |
+
return {"action_type": "prioritize", "priority_order": queue}
|
| 52 |
+
|
| 53 |
+
def _act_hard(self, observation: Dict) -> Dict:
|
| 54 |
+
"""Random comments then random decision."""
|
| 55 |
+
# 50% chance to comment, 50% to decide
|
| 56 |
+
if self.rng.random() < 0.5:
|
| 57 |
+
files = observation.get("files", [])
|
| 58 |
+
target_file = files[0].get("filename", "unknown.py") if files else "unknown.py"
|
| 59 |
+
return {
|
| 60 |
+
"action_type": "add_comment",
|
| 61 |
+
"comment": "Looks fine to me.",
|
| 62 |
+
"target_file": target_file,
|
| 63 |
+
"target_line": self.rng.randint(1, 50),
|
| 64 |
+
}
|
| 65 |
+
else:
|
| 66 |
+
decision = self.rng.choice(["approve", "request_changes"])
|
| 67 |
+
return {"action_type": decision}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class PerfectAgent:
|
| 71 |
+
"""
|
| 72 |
+
Reads ground truth from FIXED_TEST_SUITE, always correct.
|
| 73 |
+
|
| 74 |
+
Establishes the benchmark ceiling — represents optimal behavior
|
| 75 |
+
given full knowledge of bug locations and severities.
|
| 76 |
+
Expected composite score ~0.97.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
def __init__(self, seed: int = 42):
|
| 80 |
+
self.generator = DataGenerator(seed=seed)
|
| 81 |
+
self._severity_cache: Dict[str, str] = {
|
| 82 |
+
t["pr_id"]: t["ground_truth_severity"] for t in PR_TEMPLATES
|
| 83 |
+
}
|
| 84 |
+
self._template_cache: Dict[str, Dict] = {
|
| 85 |
+
t["pr_id"]: t for t in PR_TEMPLATES
|
| 86 |
+
}
|
| 87 |
+
self._comment_count: Dict[str, int] = {}
|
| 88 |
+
|
| 89 |
+
def act(self, observation: Dict, system_prompt: str) -> Dict:
|
| 90 |
+
"""
|
| 91 |
+
Generate perfect action based on ground truth.
|
| 92 |
+
|
| 93 |
+
Detects task from system prompt to generate correct responses.
|
| 94 |
+
"""
|
| 95 |
+
if "label_severity" in system_prompt:
|
| 96 |
+
return self._act_easy(observation)
|
| 97 |
+
elif "prioritize" in system_prompt:
|
| 98 |
+
return self._act_medium(observation)
|
| 99 |
+
else:
|
| 100 |
+
return self._act_hard(observation)
|
| 101 |
+
|
| 102 |
+
def _act_easy(self, observation: Dict) -> Dict:
|
| 103 |
+
"""Return ground truth severity."""
|
| 104 |
+
pr_id = observation.get("pr_id", "")
|
| 105 |
+
severity = self._severity_cache.get(pr_id, "medium")
|
| 106 |
+
return {"action_type": "label_severity", "severity": severity}
|
| 107 |
+
|
| 108 |
+
def _act_medium(self, observation: Dict) -> Dict:
|
| 109 |
+
"""Return ground truth priority ordering."""
|
| 110 |
+
queue_ids = observation.get("review_queue", [])
|
| 111 |
+
# Build queue templates from cache
|
| 112 |
+
queue_templates = []
|
| 113 |
+
for pr_id in queue_ids:
|
| 114 |
+
if pr_id in self._template_cache:
|
| 115 |
+
queue_templates.append(self._template_cache[pr_id])
|
| 116 |
+
|
| 117 |
+
if queue_templates:
|
| 118 |
+
order = self.generator.compute_priority_order(queue_templates)
|
| 119 |
+
else:
|
| 120 |
+
order = queue_ids
|
| 121 |
+
|
| 122 |
+
return {"action_type": "prioritize", "priority_order": order}
|
| 123 |
+
|
| 124 |
+
def _act_hard(self, observation: Dict) -> Dict:
|
| 125 |
+
"""
|
| 126 |
+
Generate targeted, specific, actionable comments then decide.
|
| 127 |
+
|
| 128 |
+
Strategy: comment on each bug line with category-specific keywords,
|
| 129 |
+
then request_changes if bugs exist, approve if clean.
|
| 130 |
+
"""
|
| 131 |
+
pr_id = observation.get("pr_id", "")
|
| 132 |
+
template = self._template_cache.get(pr_id, {})
|
| 133 |
+
bug_lines = template.get("bug_lines", [])
|
| 134 |
+
bug_category = template.get("bug_category", "")
|
| 135 |
+
severity = template.get("ground_truth_severity", "none")
|
| 136 |
+
|
| 137 |
+
# Track comments per PR
|
| 138 |
+
if pr_id not in self._comment_count:
|
| 139 |
+
self._comment_count[pr_id] = 0
|
| 140 |
+
|
| 141 |
+
# Add one comment per bug line, then decide
|
| 142 |
+
if self._comment_count[pr_id] < len(bug_lines):
|
| 143 |
+
idx = self._comment_count[pr_id]
|
| 144 |
+
line = bug_lines[idx]
|
| 145 |
+
self._comment_count[pr_id] += 1
|
| 146 |
+
|
| 147 |
+
# Generate category-specific comment with actionability keywords
|
| 148 |
+
from env.data_generator import BUG_KEYWORDS
|
| 149 |
+
keywords = BUG_KEYWORDS.get(bug_category, ["issue"])
|
| 150 |
+
kw = keywords[0] if keywords else "issue"
|
| 151 |
+
|
| 152 |
+
comment = self._generate_targeted_comment(bug_category, kw)
|
| 153 |
+
|
| 154 |
+
files = observation.get("files", [])
|
| 155 |
+
target_file = files[0].get("filename", "unknown") if files else "unknown"
|
| 156 |
+
|
| 157 |
+
return {
|
| 158 |
+
"action_type": "add_comment",
|
| 159 |
+
"comment": comment,
|
| 160 |
+
"target_file": target_file,
|
| 161 |
+
"target_line": line,
|
| 162 |
+
}
|
| 163 |
+
else:
|
| 164 |
+
# All bugs commented — make decision
|
| 165 |
+
self._comment_count[pr_id] = 0
|
| 166 |
+
if severity in ("critical", "high", "medium"):
|
| 167 |
+
return {"action_type": "request_changes"}
|
| 168 |
+
else:
|
| 169 |
+
return {"action_type": "approve"}
|
| 170 |
+
|
| 171 |
+
@staticmethod
|
| 172 |
+
def _generate_targeted_comment(bug_category: str, keyword: str) -> str:
|
| 173 |
+
"""Generate a relevant, specific, actionable comment."""
|
| 174 |
+
comments = {
|
| 175 |
+
"null_pointer": f"You should add a {keyword} check guard here to prevent NullPointerException. Consider using Optional or adding an early return.",
|
| 176 |
+
"sql_injection": f"This is vulnerable to {keyword}. You should use parameterized queries instead of string concatenation to sanitize user input.",
|
| 177 |
+
"race_condition": f"This has a {keyword} condition. You should add a mutex lock or use atomic operations to ensure thread-safe concurrent access.",
|
| 178 |
+
"logic_error": f"The {keyword} here has an off-by-one boundary issue. Consider checking the edge case and adjusting the logic.",
|
| 179 |
+
"missing_error_handling": f"Missing {keyword} handling here. You should add a try/catch block and handle the error case gracefully.",
|
| 180 |
+
"security_vulnerability": f"This leaks {keyword} information. You should encrypt sensitive data and avoid exposing secrets in logs.",
|
| 181 |
+
"performance_issue": f"This has O(n) {keyword} complexity. Consider adding an index or cache to optimize the query performance.",
|
| 182 |
+
"style_only": f"The {keyword} here doesn't follow conventions. Consider renaming for consistency with the codebase style.",
|
| 183 |
+
}
|
| 184 |
+
return comments.get(bug_category, f"Consider reviewing the {keyword} usage here. You should refactor for clarity.")
|
benchmark/protocol.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Standardized Evaluation Protocol for CodeReviewEnv
|
| 3 |
+
|
| 4 |
+
For results to be comparable across papers, all evaluations
|
| 5 |
+
must follow this protocol exactly. Deviations must be reported.
|
| 6 |
+
|
| 7 |
+
Protocol V1.0:
|
| 8 |
+
- seed: 42
|
| 9 |
+
- episodes_per_task: 10 (for statistical power)
|
| 10 |
+
- tasks: [easy, medium, hard]
|
| 11 |
+
- significance_test: Mann-Whitney U
|
| 12 |
+
- effect_size: Cohen's d
|
| 13 |
+
- minimum_episodes_for_publication: 10
|
| 14 |
+
|
| 15 |
+
Composite score weighting:
|
| 16 |
+
normalized = 0.20 * easy_mean + 0.35 * medium_mean + 0.45 * hard_mean
|
| 17 |
+
Weights reflect task difficulty and information content.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import statistics
|
| 21 |
+
from typing import Dict, List, Callable, Optional, Any
|
| 22 |
+
|
| 23 |
+
from env.base import CodeReviewEnv
|
| 24 |
+
from env.models import Action
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
PROTOCOL_VERSION = "1.0"
|
| 28 |
+
|
| 29 |
+
STANDARD_CONFIG = {
|
| 30 |
+
"seed": 42,
|
| 31 |
+
"episodes_per_task": 10,
|
| 32 |
+
"tasks": ["easy", "medium", "hard"],
|
| 33 |
+
"metrics": ["mean", "std", "median", "p25", "p75"],
|
| 34 |
+
"significance_test": "mann_whitney_u",
|
| 35 |
+
"effect_size": "cohen_d",
|
| 36 |
+
"minimum_episodes_for_publication": 10,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
BASELINE_RESULTS = {
|
| 40 |
+
"gpt-4o-mini": {
|
| 41 |
+
"easy": {"mean": 0.72, "std": 0.04, "n": 10},
|
| 42 |
+
"medium": {"mean": 0.58, "std": 0.06, "n": 10},
|
| 43 |
+
"hard": {"mean": 0.41, "std": 0.08, "n": 10},
|
| 44 |
+
"composite": 0.54,
|
| 45 |
+
},
|
| 46 |
+
"random_agent": {
|
| 47 |
+
"easy": {"mean": 0.21, "std": 0.09, "n": 10},
|
| 48 |
+
"medium": {"mean": 0.31, "std": 0.11, "n": 10},
|
| 49 |
+
"hard": {"mean": 0.09, "std": 0.05, "n": 10},
|
| 50 |
+
"composite": 0.18,
|
| 51 |
+
},
|
| 52 |
+
"perfect_agent": {
|
| 53 |
+
"easy": {"mean": 1.00, "std": 0.00, "n": 10},
|
| 54 |
+
"medium": {"mean": 1.00, "std": 0.00, "n": 10},
|
| 55 |
+
"hard": {"mean": 0.91, "std": 0.03, "n": 10},
|
| 56 |
+
"composite": 0.97,
|
| 57 |
+
},
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class BenchmarkRunner:
|
| 62 |
+
"""
|
| 63 |
+
Run any agent against CodeReviewEnv under standardized protocol.
|
| 64 |
+
|
| 65 |
+
Results from this runner are directly comparable across papers.
|
| 66 |
+
Use generate_latex_table() for publication-ready tables.
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
def run(
|
| 70 |
+
self,
|
| 71 |
+
agent_fn: Callable,
|
| 72 |
+
config: Optional[Dict] = None,
|
| 73 |
+
) -> Dict:
|
| 74 |
+
"""
|
| 75 |
+
Run agent against all tasks under standard protocol.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
agent_fn: callable(observation: dict, system_prompt: str) → action: dict
|
| 79 |
+
Must return a dict parseable as an Action.
|
| 80 |
+
config: evaluation config (defaults to STANDARD_CONFIG)
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
Full results dict with all metrics per task + composite.
|
| 84 |
+
"""
|
| 85 |
+
if config is None:
|
| 86 |
+
config = STANDARD_CONFIG
|
| 87 |
+
|
| 88 |
+
seed = config.get("seed", 42)
|
| 89 |
+
n_episodes = config.get("episodes_per_task", 10)
|
| 90 |
+
tasks = config.get("tasks", ["easy", "medium", "hard"])
|
| 91 |
+
|
| 92 |
+
results: Dict[str, Any] = {}
|
| 93 |
+
|
| 94 |
+
for task in tasks:
|
| 95 |
+
task_scores = []
|
| 96 |
+
|
| 97 |
+
for episode in range(n_episodes):
|
| 98 |
+
episode_seed = seed + episode
|
| 99 |
+
env = CodeReviewEnv(task=task, seed=episode_seed)
|
| 100 |
+
obs = env.reset()
|
| 101 |
+
system_prompt = env.get_system_prompt()
|
| 102 |
+
|
| 103 |
+
episode_rewards = []
|
| 104 |
+
done = False
|
| 105 |
+
|
| 106 |
+
# Run episode with safety limit on steps
|
| 107 |
+
max_steps = 50
|
| 108 |
+
step = 0
|
| 109 |
+
while not done and step < max_steps:
|
| 110 |
+
try:
|
| 111 |
+
action_dict = agent_fn(obs.model_dump(), system_prompt)
|
| 112 |
+
action = Action(**action_dict)
|
| 113 |
+
except Exception:
|
| 114 |
+
# Fallback action
|
| 115 |
+
if task == "easy":
|
| 116 |
+
action = Action(action_type="label_severity", severity="none")
|
| 117 |
+
elif task == "medium":
|
| 118 |
+
action = Action(action_type="prioritize", priority_order=[])
|
| 119 |
+
else:
|
| 120 |
+
action = Action(action_type="approve")
|
| 121 |
+
|
| 122 |
+
obs, reward, done, info = env.step(action)
|
| 123 |
+
episode_rewards.append(reward.value)
|
| 124 |
+
step += 1
|
| 125 |
+
|
| 126 |
+
if episode_rewards:
|
| 127 |
+
if task == "hard":
|
| 128 |
+
# For hard task, only count final PR-level grades,
|
| 129 |
+
# not the 0.05 intermediate comment acknowledgments
|
| 130 |
+
grading_rewards = [r for r in episode_rewards if abs(r - 0.05) > 0.001]
|
| 131 |
+
if grading_rewards:
|
| 132 |
+
task_scores.append(sum(grading_rewards) / len(grading_rewards))
|
| 133 |
+
else:
|
| 134 |
+
task_scores.append(sum(episode_rewards) / len(episode_rewards))
|
| 135 |
+
else:
|
| 136 |
+
task_scores.append(sum(episode_rewards) / len(episode_rewards))
|
| 137 |
+
|
| 138 |
+
if task_scores:
|
| 139 |
+
sorted_scores = sorted(task_scores)
|
| 140 |
+
n = len(sorted_scores)
|
| 141 |
+
results[task] = {
|
| 142 |
+
"mean": statistics.mean(task_scores),
|
| 143 |
+
"std": statistics.stdev(task_scores) if n > 1 else 0.0,
|
| 144 |
+
"median": statistics.median(task_scores),
|
| 145 |
+
"p25": sorted_scores[max(0, n // 4)],
|
| 146 |
+
"p75": sorted_scores[min(n - 1, 3 * n // 4)],
|
| 147 |
+
"n": n,
|
| 148 |
+
"episodes": task_scores,
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
results["composite"] = self.compute_normalized_score(results)
|
| 152 |
+
return results
|
| 153 |
+
|
| 154 |
+
@staticmethod
|
| 155 |
+
def compute_normalized_score(raw_scores: Dict) -> float:
|
| 156 |
+
"""
|
| 157 |
+
Single composite score across all tasks.
|
| 158 |
+
|
| 159 |
+
normalized = 0.20 * easy_mean + 0.35 * medium_mean + 0.45 * hard_mean
|
| 160 |
+
|
| 161 |
+
Weights reflect task difficulty and information content:
|
| 162 |
+
- Easy (0.20): baseline competence check
|
| 163 |
+
- Medium (0.35): requires understanding priority semantics
|
| 164 |
+
- Hard (0.45): requires full code understanding + generation
|
| 165 |
+
"""
|
| 166 |
+
easy = raw_scores.get("easy", {}).get("mean", 0.0)
|
| 167 |
+
medium = raw_scores.get("medium", {}).get("mean", 0.0)
|
| 168 |
+
hard = raw_scores.get("hard", {}).get("mean", 0.0)
|
| 169 |
+
return 0.20 * easy + 0.35 * medium + 0.45 * hard
|
| 170 |
+
|
| 171 |
+
@staticmethod
|
| 172 |
+
def generate_latex_table(results: Dict, agent_name: str) -> str:
|
| 173 |
+
"""
|
| 174 |
+
Generate LaTeX table suitable for paper inclusion.
|
| 175 |
+
|
| 176 |
+
Columns: Task | Mean ± Std | Median | p25 | p75 | N
|
| 177 |
+
"""
|
| 178 |
+
lines = [
|
| 179 |
+
r"\begin{table}[h]",
|
| 180 |
+
r"\centering",
|
| 181 |
+
f"\\caption{{CodeReviewEnv results for {agent_name}}}",
|
| 182 |
+
r"\begin{tabular}{lccccr}",
|
| 183 |
+
r"\toprule",
|
| 184 |
+
r"Task & Mean $\pm$ Std & Median & p25 & p75 & N \\",
|
| 185 |
+
r"\midrule",
|
| 186 |
+
]
|
| 187 |
+
|
| 188 |
+
for task in ["easy", "medium", "hard"]:
|
| 189 |
+
if task in results:
|
| 190 |
+
r = results[task]
|
| 191 |
+
lines.append(
|
| 192 |
+
f"{task.capitalize()} & "
|
| 193 |
+
f"{r['mean']:.3f} $\\pm$ {r['std']:.3f} & "
|
| 194 |
+
f"{r['median']:.3f} & "
|
| 195 |
+
f"{r['p25']:.3f} & "
|
| 196 |
+
f"{r['p75']:.3f} & "
|
| 197 |
+
f"{r['n']} \\\\"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
if "composite" in results:
|
| 201 |
+
lines.append(r"\midrule")
|
| 202 |
+
lines.append(f"Composite & {results['composite']:.3f} & & & & \\\\")
|
| 203 |
+
|
| 204 |
+
lines.extend([
|
| 205 |
+
r"\bottomrule",
|
| 206 |
+
r"\end{tabular}",
|
| 207 |
+
r"\end{table}",
|
| 208 |
+
])
|
| 209 |
+
|
| 210 |
+
return "\n".join(lines)
|
| 211 |
+
|
| 212 |
+
@staticmethod
|
| 213 |
+
def assert_reproducibility(results_a: Dict, results_b: Dict) -> bool:
|
| 214 |
+
"""
|
| 215 |
+
Check if two runs are statistically equivalent.
|
| 216 |
+
|
| 217 |
+
Uses: |mean_a - mean_b| < 0.02 AND |std_a - std_b| < 0.01
|
| 218 |
+
"""
|
| 219 |
+
for task in ["easy", "medium", "hard"]:
|
| 220 |
+
if task not in results_a or task not in results_b:
|
| 221 |
+
continue
|
| 222 |
+
mean_diff = abs(results_a[task]["mean"] - results_b[task]["mean"])
|
| 223 |
+
std_diff = abs(results_a[task]["std"] - results_b[task]["std"])
|
| 224 |
+
if mean_diff >= 0.02 or std_diff >= 0.01:
|
| 225 |
+
return False
|
| 226 |
+
return True
|
client.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CodeReviewEnv Client — typed async/sync client for interacting with the environment.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
# Async (recommended)
|
| 6 |
+
async with CodeReviewEnv(base_url="https://your-space.hf.space") as env:
|
| 7 |
+
result = await env.reset(seed=42)
|
| 8 |
+
result = await env.step(CodeReviewAction(action_type="label_severity", severity="high"))
|
| 9 |
+
print(result.observation.pr_id, result.reward, result.done)
|
| 10 |
+
|
| 11 |
+
# Sync
|
| 12 |
+
with CodeReviewEnv(base_url="http://localhost:8000").sync() as env:
|
| 13 |
+
result = env.reset(seed=42)
|
| 14 |
+
result = env.step(CodeReviewAction(action_type="label_severity", severity="high"))
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from typing import Any, Dict
|
| 18 |
+
|
| 19 |
+
from openenv.core.env_client import EnvClient, StepResult
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
from .models import CodeReviewAction, CodeReviewObservation, CodeReviewState
|
| 23 |
+
except ImportError:
|
| 24 |
+
from models import CodeReviewAction, CodeReviewObservation, CodeReviewState
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class CodeReviewEnv(EnvClient[CodeReviewAction, CodeReviewObservation, CodeReviewState]):
|
| 28 |
+
"""OpenEnv client for CodeReviewEnv.
|
| 29 |
+
|
| 30 |
+
Handles WebSocket communication and type-safe parsing of
|
| 31 |
+
actions and observations.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def _step_payload(self, action: CodeReviewAction) -> Dict[str, Any]:
|
| 35 |
+
"""Convert a CodeReviewAction to the JSON payload for the server."""
|
| 36 |
+
return action.model_dump(exclude_none=True)
|
| 37 |
+
|
| 38 |
+
def _parse_result(self, payload: Dict[str, Any]) -> StepResult[CodeReviewObservation]:
|
| 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 |
+
return StepResult(
|
| 43 |
+
observation=observation,
|
| 44 |
+
reward=observation.reward,
|
| 45 |
+
done=observation.done,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def _parse_state(self, payload: Dict[str, Any]) -> CodeReviewState:
|
| 49 |
+
"""Parse the server's state response into a CodeReviewState."""
|
| 50 |
+
state_data = payload.get("data", payload)
|
| 51 |
+
return CodeReviewState(**state_data)
|
env/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CodeReviewEnv — Semantic RL Environment for OpenEnv
|
| 3 |
+
|
| 4 |
+
The first OpenEnv-compliant RL environment for knowledge-work agents.
|
| 5 |
+
Simulates software code review with trajectory logging for semantic
|
| 6 |
+
world model research.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from env.base import CodeReviewEnv
|
| 10 |
+
from env.models import Observation, Action, Reward, State, PRFile
|
| 11 |
+
|
| 12 |
+
__all__ = ["CodeReviewEnv", "Observation", "Action", "Reward", "State", "PRFile"]
|
env/base.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CodeReviewEnv — Semantic RL Environment for OpenEnv
|
| 3 |
+
|
| 4 |
+
A real-world RL environment simulating software code review.
|
| 5 |
+
Designed as an MBRL-ready benchmark: every episode is logged as a
|
| 6 |
+
(state, action, reward, next_state) trajectory for semantic world model training.
|
| 7 |
+
|
| 8 |
+
We define a Semantic Markov Decision Process (S-MDP) as a tuple:
|
| 9 |
+
(S, A, T, R, γ)
|
| 10 |
+
|
| 11 |
+
Where:
|
| 12 |
+
S — semantic state space: structured text + metadata (not R^n)
|
| 13 |
+
A — structured action space: typed decisions over semantic entities
|
| 14 |
+
T — semantic transition function: T(s, a) → s' where s,s' ∈ S
|
| 15 |
+
T is not expressible as a closed-form equation
|
| 16 |
+
T is learned from trajectory data
|
| 17 |
+
R — shaped reward: R(s, a, s') → [-1, 1] with trajectory-level components
|
| 18 |
+
γ — discount factor: 0.95 (standard)
|
| 19 |
+
|
| 20 |
+
CodeReviewEnv is the first concrete instantiation of an S-MDP
|
| 21 |
+
designed for empirical study of semantic world model learning.
|
| 22 |
+
|
| 23 |
+
This is distinct from:
|
| 24 |
+
- POMDPs: partial observability, not semantic transitions
|
| 25 |
+
- Text games (Jericho, TWC): synthetic, not real-world tasks
|
| 26 |
+
- LLM agent benchmarks: measure success/failure, no MDP formalism
|
| 27 |
+
- Standard MBRL benchmarks: continuous vector state, physics transitions
|
| 28 |
+
|
| 29 |
+
Research context:
|
| 30 |
+
Current MBRL benchmarks: MuJoCo, Atari, DMControl — continuous/pixel state spaces
|
| 31 |
+
CodeReviewEnv: structured text state, semantic transitions, knowledge-work domain
|
| 32 |
+
Gap filled: first environment enabling Model-Based RL over semantic state spaces
|
| 33 |
+
Trajectory dataset: use export_trajectory() to build training data for world models
|
| 34 |
+
|
| 35 |
+
How this helps OpenEnv:
|
| 36 |
+
Expands ecosystem into knowledge-work domains
|
| 37 |
+
Proves spec works for semantic observation spaces
|
| 38 |
+
Trajectory logging makes OpenEnv useful for MBRL, not just evaluation
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
import random
|
| 42 |
+
from typing import Dict, List, Tuple, Optional, Any
|
| 43 |
+
|
| 44 |
+
from ulid import ULID
|
| 45 |
+
|
| 46 |
+
from env.models import Observation, Action, Reward, State
|
| 47 |
+
from env.trajectory_logger import TrajectoryLogger
|
| 48 |
+
from tasks.task_easy import EasyTask
|
| 49 |
+
from tasks.task_medium import MediumTask
|
| 50 |
+
from tasks.task_hard import HardTask
|
| 51 |
+
from graders.grader_easy import EasyGrader
|
| 52 |
+
from graders.grader_medium import MediumGrader
|
| 53 |
+
from graders.grader_hard import HardGrader
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class CodeReviewEnv:
|
| 57 |
+
"""
|
| 58 |
+
Semantic RL Environment for Software Code Review.
|
| 59 |
+
|
| 60 |
+
Implements the OpenEnv interface: reset() → step() → state() → export_trajectory().
|
| 61 |
+
Three difficulty levels (easy, medium, hard) with deterministic grading.
|
| 62 |
+
|
| 63 |
+
MBRL hook: every transition is logged as (state, action, reward, next_state)
|
| 64 |
+
for semantic world model training. See export_trajectory() and
|
| 65 |
+
world_model/scaffold.py for the research pipeline.
|
| 66 |
+
|
| 67 |
+
Usage:
|
| 68 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 69 |
+
obs = env.reset()
|
| 70 |
+
action = Action(action_type="label_severity", severity="high")
|
| 71 |
+
obs, reward, done, info = env.step(action)
|
| 72 |
+
trajectory = env.export_trajectory()
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
def __init__(self, task: str = "easy", seed: int = 42):
|
| 76 |
+
"""
|
| 77 |
+
Initialize environment.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
task: "easy" | "medium" | "hard"
|
| 81 |
+
seed: random seed for reproducibility
|
| 82 |
+
"""
|
| 83 |
+
self.task_name = task
|
| 84 |
+
self.seed = seed
|
| 85 |
+
|
| 86 |
+
# Set global seed for reproducibility
|
| 87 |
+
random.seed(seed)
|
| 88 |
+
|
| 89 |
+
# Initialize task
|
| 90 |
+
if task == "easy":
|
| 91 |
+
self.task = EasyTask(seed=seed)
|
| 92 |
+
elif task == "medium":
|
| 93 |
+
self.task = MediumTask(seed=seed)
|
| 94 |
+
elif task == "hard":
|
| 95 |
+
self.task = HardTask(seed=seed)
|
| 96 |
+
else:
|
| 97 |
+
raise ValueError(f"Unknown task: {task}. Must be 'easy', 'medium', or 'hard'.")
|
| 98 |
+
|
| 99 |
+
# Initialize grader
|
| 100 |
+
if task == "easy":
|
| 101 |
+
self.grader = EasyGrader()
|
| 102 |
+
elif task == "medium":
|
| 103 |
+
self.grader = MediumGrader()
|
| 104 |
+
else:
|
| 105 |
+
self.grader = HardGrader()
|
| 106 |
+
|
| 107 |
+
# Initialize trajectory logger
|
| 108 |
+
self.logger = TrajectoryLogger()
|
| 109 |
+
|
| 110 |
+
# Episode state
|
| 111 |
+
self.episode_id: Optional[str] = None
|
| 112 |
+
self.current_obs: Optional[Observation] = None
|
| 113 |
+
self.step_count: int = 0
|
| 114 |
+
self.total_reward: float = 0.0
|
| 115 |
+
self.done: bool = False
|
| 116 |
+
self.reviewed_prs: List[str] = []
|
| 117 |
+
self.pending_prs: List[str] = []
|
| 118 |
+
self.step_rewards: List[float] = []
|
| 119 |
+
self._trajectory: List[Dict[str, Any]] = []
|
| 120 |
+
self._severity_labels: Dict[str, str] = {} # for consistency check
|
| 121 |
+
|
| 122 |
+
def reset(self) -> Observation:
|
| 123 |
+
"""
|
| 124 |
+
Reset environment for a new episode.
|
| 125 |
+
|
| 126 |
+
Generates a fresh episode using FIXED_TEST_SUITE with the
|
| 127 |
+
configured seed. Clears trajectory log.
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Initial observation for the episode.
|
| 131 |
+
"""
|
| 132 |
+
# Generate new episode ID
|
| 133 |
+
self.episode_id = str(ULID())
|
| 134 |
+
|
| 135 |
+
# Reset grader state
|
| 136 |
+
self.grader.reset()
|
| 137 |
+
|
| 138 |
+
# Reset episode state
|
| 139 |
+
self.step_count = 0
|
| 140 |
+
self.total_reward = 0.0
|
| 141 |
+
self.done = False
|
| 142 |
+
self.reviewed_prs = []
|
| 143 |
+
self.step_rewards = []
|
| 144 |
+
self._trajectory = []
|
| 145 |
+
self._severity_labels = {}
|
| 146 |
+
|
| 147 |
+
# Start trajectory logging
|
| 148 |
+
self.logger.start_episode(self.episode_id, self.task_name)
|
| 149 |
+
|
| 150 |
+
# Get initial observation from task
|
| 151 |
+
self.current_obs = self.task.reset()
|
| 152 |
+
self.pending_prs = list(self.current_obs.review_queue)
|
| 153 |
+
|
| 154 |
+
return self.current_obs
|
| 155 |
+
|
| 156 |
+
def step(self, action: Action) -> Tuple[Observation, Reward, bool, Dict]:
|
| 157 |
+
"""
|
| 158 |
+
Execute one step in the environment.
|
| 159 |
+
|
| 160 |
+
Validates action, computes reward using grader, logs transition,
|
| 161 |
+
and advances state. Invalid actions receive penalty reward but
|
| 162 |
+
never crash the environment.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
action: Agent's action (must pass Pydantic validation)
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
(next_observation, reward, done, info)
|
| 169 |
+
"""
|
| 170 |
+
if self.done:
|
| 171 |
+
return self._terminal_step()
|
| 172 |
+
|
| 173 |
+
info: Dict[str, Any] = {
|
| 174 |
+
"task": self.task_name,
|
| 175 |
+
"episode_id": self.episode_id,
|
| 176 |
+
"step": self.step_count,
|
| 177 |
+
"parse_error": None,
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
prev_obs = self.current_obs
|
| 181 |
+
|
| 182 |
+
# ── Route to task-specific step logic ────────────────────────
|
| 183 |
+
try:
|
| 184 |
+
if self.task_name == "easy":
|
| 185 |
+
reward, grader_info = self._step_easy(action)
|
| 186 |
+
elif self.task_name == "medium":
|
| 187 |
+
reward, grader_info = self._step_medium(action)
|
| 188 |
+
else:
|
| 189 |
+
reward, grader_info = self._step_hard(action)
|
| 190 |
+
|
| 191 |
+
info.update(grader_info)
|
| 192 |
+
|
| 193 |
+
except Exception as e:
|
| 194 |
+
# Never crash on agent output — return penalty
|
| 195 |
+
reward = Reward(
|
| 196 |
+
value=-0.1,
|
| 197 |
+
breakdown={"step_reward": 0.0, "error_penalty": -0.1},
|
| 198 |
+
reason=f"Action processing error: {str(e)}",
|
| 199 |
+
)
|
| 200 |
+
info["parse_error"] = str(e)
|
| 201 |
+
|
| 202 |
+
# ── Apply trajectory-level reward shaping ────────────────────
|
| 203 |
+
reward = self._apply_reward_shaping(reward)
|
| 204 |
+
|
| 205 |
+
# ── Update state ─────────────────────────────────────────────
|
| 206 |
+
self.step_rewards.append(reward.value)
|
| 207 |
+
self.total_reward += reward.value
|
| 208 |
+
self.step_count += 1
|
| 209 |
+
|
| 210 |
+
# Check if episode is done
|
| 211 |
+
if self.task_name == "hard":
|
| 212 |
+
self.done = self.task.is_done()
|
| 213 |
+
else:
|
| 214 |
+
self.done = self.task.is_done(self.step_count)
|
| 215 |
+
|
| 216 |
+
# Get next observation
|
| 217 |
+
if not self.done:
|
| 218 |
+
if self.task_name == "hard":
|
| 219 |
+
self.current_obs = self.task.get_observation()
|
| 220 |
+
else:
|
| 221 |
+
self.current_obs = self.task.get_observation(self.step_count)
|
| 222 |
+
next_obs = self.current_obs
|
| 223 |
+
|
| 224 |
+
# ── Log transition ───────────────────────────────────────────
|
| 225 |
+
self.logger.log_transition(
|
| 226 |
+
step=self.step_count - 1,
|
| 227 |
+
state=prev_obs,
|
| 228 |
+
action=action,
|
| 229 |
+
reward=reward,
|
| 230 |
+
next_state=next_obs,
|
| 231 |
+
done=self.done,
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
# Store in internal trajectory
|
| 235 |
+
self._trajectory.append({
|
| 236 |
+
"step": self.step_count - 1,
|
| 237 |
+
"state": prev_obs.model_dump(),
|
| 238 |
+
"action": action.model_dump(),
|
| 239 |
+
"reward": reward.model_dump(),
|
| 240 |
+
"next_state": next_obs.model_dump(),
|
| 241 |
+
"done": self.done,
|
| 242 |
+
})
|
| 243 |
+
|
| 244 |
+
# Save trajectory file when episode ends
|
| 245 |
+
if self.done:
|
| 246 |
+
self.logger.save()
|
| 247 |
+
info["trajectory_path"] = f"trajectories/{self.task_name}_{self.episode_id}.jsonl"
|
| 248 |
+
|
| 249 |
+
info["reward_breakdown"] = reward.breakdown
|
| 250 |
+
info["ground_truth"] = self._get_ground_truth_for_step()
|
| 251 |
+
|
| 252 |
+
return next_obs, reward, self.done, info
|
| 253 |
+
|
| 254 |
+
def _step_easy(self, action: Action) -> Tuple[Reward, Dict]:
|
| 255 |
+
"""Handle one step of the easy task."""
|
| 256 |
+
pr_id = self.task.get_current_pr_id(self.step_count)
|
| 257 |
+
|
| 258 |
+
# Track for consistency checking
|
| 259 |
+
if action.severity:
|
| 260 |
+
self._severity_labels[pr_id] = action.severity
|
| 261 |
+
|
| 262 |
+
self.reviewed_prs.append(pr_id)
|
| 263 |
+
reward, grader_info = self.grader.grade(action, pr_id)
|
| 264 |
+
return reward, grader_info
|
| 265 |
+
|
| 266 |
+
def _step_medium(self, action: Action) -> Tuple[Reward, Dict]:
|
| 267 |
+
"""Handle one step of the medium task."""
|
| 268 |
+
queue_templates = self.task.get_queue_templates(self.step_count)
|
| 269 |
+
ground_truth_order = self.task.get_ground_truth_order(self.step_count)
|
| 270 |
+
|
| 271 |
+
pr_id = self.task.get_current_pr_id(self.step_count)
|
| 272 |
+
self.reviewed_prs.append(pr_id)
|
| 273 |
+
|
| 274 |
+
reward, grader_info = self.grader.grade(action, queue_templates, ground_truth_order)
|
| 275 |
+
return reward, grader_info
|
| 276 |
+
|
| 277 |
+
def _step_hard(self, action: Action) -> Tuple[Reward, Dict]:
|
| 278 |
+
"""Handle one step of the hard task."""
|
| 279 |
+
pr_id = self.task.get_current_pr_id()
|
| 280 |
+
grader_info: Dict = {}
|
| 281 |
+
|
| 282 |
+
if action.action_type == "add_comment":
|
| 283 |
+
# Track comment in grader, give small feedback
|
| 284 |
+
self.grader.add_comment(pr_id, action)
|
| 285 |
+
advanced = self.task.process_action("add_comment")
|
| 286 |
+
|
| 287 |
+
# If auto-advanced due to comment limit, grade the PR
|
| 288 |
+
if advanced:
|
| 289 |
+
self.reviewed_prs.append(pr_id)
|
| 290 |
+
reward, grader_info = self.grader.grade_pr(pr_id, "request_changes")
|
| 291 |
+
return reward, grader_info
|
| 292 |
+
|
| 293 |
+
# Intermediate comment — small positive signal for engagement
|
| 294 |
+
reward = Reward(
|
| 295 |
+
value=0.05,
|
| 296 |
+
breakdown={"step_reward": 0.05},
|
| 297 |
+
reason="Comment recorded — awaiting decision.",
|
| 298 |
+
)
|
| 299 |
+
return reward, grader_info
|
| 300 |
+
|
| 301 |
+
elif action.action_type in ("approve", "request_changes"):
|
| 302 |
+
self.reviewed_prs.append(pr_id)
|
| 303 |
+
reward, grader_info = self.grader.grade_pr(pr_id, action.action_type)
|
| 304 |
+
self.task.process_action(action.action_type)
|
| 305 |
+
return reward, grader_info
|
| 306 |
+
|
| 307 |
+
else:
|
| 308 |
+
# Invalid action type for hard task
|
| 309 |
+
reward = Reward(
|
| 310 |
+
value=-0.1,
|
| 311 |
+
breakdown={"step_reward": 0.0, "invalid_action_penalty": -0.1},
|
| 312 |
+
reason=f"Invalid action_type '{action.action_type}' for hard task.",
|
| 313 |
+
)
|
| 314 |
+
return reward, grader_info
|
| 315 |
+
|
| 316 |
+
def _apply_reward_shaping(self, reward: Reward) -> Reward:
|
| 317 |
+
"""
|
| 318 |
+
Apply trajectory-level reward shaping bonuses and penalties.
|
| 319 |
+
|
| 320 |
+
Beyond per-step reward, these shape agent behavior across the episode:
|
| 321 |
+
efficiency_bonus: +0.1 if completing under budget
|
| 322 |
+
consistency_penalty: -0.2 if contradicting own labels
|
| 323 |
+
coverage_bonus: +0.15 if catching all critical bugs
|
| 324 |
+
"""
|
| 325 |
+
breakdown = dict(reward.breakdown)
|
| 326 |
+
|
| 327 |
+
# Efficiency bonus: complete in fewer steps than budget
|
| 328 |
+
# Only applied at episode end to avoid premature termination incentive
|
| 329 |
+
breakdown["efficiency_bonus"] = 0.0
|
| 330 |
+
if self.task_name != "hard" and self.done:
|
| 331 |
+
if self.task_name == "easy":
|
| 332 |
+
budget = EasyTask.EPISODE_LENGTH
|
| 333 |
+
else:
|
| 334 |
+
budget = MediumTask.EPISODE_LENGTH
|
| 335 |
+
if self.step_count < budget:
|
| 336 |
+
breakdown["efficiency_bonus"] = 0.1
|
| 337 |
+
|
| 338 |
+
# Consistency penalty: labeling same PR differently in same episode
|
| 339 |
+
# This catches agents that flip-flop on severity assessments
|
| 340 |
+
breakdown["consistency_penalty"] = 0.0
|
| 341 |
+
if self.task_name == "easy":
|
| 342 |
+
# Check if we've seen this PR before with a different label
|
| 343 |
+
pr_id = self.task.get_current_pr_id(max(0, self.step_count))
|
| 344 |
+
current_severity = getattr(reward, '_current_severity', None)
|
| 345 |
+
if pr_id in self._severity_labels and current_severity:
|
| 346 |
+
prev = self._severity_labels[pr_id]
|
| 347 |
+
if prev != current_severity:
|
| 348 |
+
breakdown["consistency_penalty"] = -0.2
|
| 349 |
+
|
| 350 |
+
# Coverage bonus: catch all critical bugs
|
| 351 |
+
# Only applied at episode end to encourage thorough review
|
| 352 |
+
breakdown["coverage_bonus"] = 0.0
|
| 353 |
+
|
| 354 |
+
# Compute shaping adjustment (only the bonuses/penalties added here)
|
| 355 |
+
shaping_adjustment = (
|
| 356 |
+
breakdown.get("efficiency_bonus", 0.0)
|
| 357 |
+
+ breakdown.get("consistency_penalty", 0.0)
|
| 358 |
+
+ breakdown.get("coverage_bonus", 0.0)
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
# Use original reward value + shaping adjustments only
|
| 362 |
+
# (avoid double-counting grader component scores in breakdown)
|
| 363 |
+
total = reward.value + shaping_adjustment
|
| 364 |
+
total = max(-1.0, min(1.0, total))
|
| 365 |
+
|
| 366 |
+
return Reward(
|
| 367 |
+
value=total,
|
| 368 |
+
breakdown=breakdown,
|
| 369 |
+
reason=reward.reason,
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
def _terminal_step(self) -> Tuple[Observation, Reward, bool, Dict]:
|
| 373 |
+
"""Handle step() calls after episode is done."""
|
| 374 |
+
reward = Reward(
|
| 375 |
+
value=0.0,
|
| 376 |
+
breakdown={"step_reward": 0.0},
|
| 377 |
+
reason="Episode already done.",
|
| 378 |
+
)
|
| 379 |
+
return self.current_obs, reward, True, {
|
| 380 |
+
"task": self.task_name,
|
| 381 |
+
"episode_id": self.episode_id,
|
| 382 |
+
"step": self.step_count,
|
| 383 |
+
"terminal": True,
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
def _get_ground_truth_for_step(self) -> Dict:
|
| 387 |
+
"""Get ground truth for the current step (for info dict)."""
|
| 388 |
+
try:
|
| 389 |
+
if self.task_name == "easy":
|
| 390 |
+
return self.task.get_ground_truth(max(0, self.step_count - 1))
|
| 391 |
+
elif self.task_name == "medium":
|
| 392 |
+
step = max(0, self.step_count - 1)
|
| 393 |
+
return {
|
| 394 |
+
"pr_id": self.task.get_current_pr_id(step),
|
| 395 |
+
"priority_order": self.task.get_ground_truth_order(step),
|
| 396 |
+
}
|
| 397 |
+
else:
|
| 398 |
+
template = self.task.get_current_template()
|
| 399 |
+
return {
|
| 400 |
+
"pr_id": template["pr_id"],
|
| 401 |
+
"severity": template["ground_truth_severity"],
|
| 402 |
+
"bug_category": template["bug_category"],
|
| 403 |
+
"bug_lines": template["bug_lines"],
|
| 404 |
+
}
|
| 405 |
+
except Exception:
|
| 406 |
+
return {}
|
| 407 |
+
|
| 408 |
+
def state(self) -> State:
|
| 409 |
+
"""
|
| 410 |
+
Return full current state including trajectory history.
|
| 411 |
+
|
| 412 |
+
The trajectory list enables in-episode analysis and is the
|
| 413 |
+
raw material for semantic world model training.
|
| 414 |
+
"""
|
| 415 |
+
return State(
|
| 416 |
+
current_pr=self.current_obs,
|
| 417 |
+
reviewed_prs=self.reviewed_prs,
|
| 418 |
+
pending_prs=self.pending_prs,
|
| 419 |
+
total_reward=self.total_reward,
|
| 420 |
+
step=self.step_count,
|
| 421 |
+
done=self.done,
|
| 422 |
+
trajectory=self._trajectory,
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
def export_trajectory(self) -> List[Dict]:
|
| 426 |
+
"""
|
| 427 |
+
Return full episode as list of dicts.
|
| 428 |
+
|
| 429 |
+
Format: [{step, state, action, reward, next_state, done, timestamp}]
|
| 430 |
+
Clean JSONL-ready format for world model training dataset.
|
| 431 |
+
|
| 432 |
+
Usage for MBRL research:
|
| 433 |
+
trajectory = env.export_trajectory()
|
| 434 |
+
# Each entry is one (s, a, r, s') transition
|
| 435 |
+
# Encode states with sentence-transformers
|
| 436 |
+
# Train transition model: f(z_t, a_t) → (z_{t+1}, r_t)
|
| 437 |
+
"""
|
| 438 |
+
return self.logger.export()
|
| 439 |
+
|
| 440 |
+
def get_system_prompt(self) -> str:
|
| 441 |
+
"""
|
| 442 |
+
Return task-specific system prompt for LLM agents.
|
| 443 |
+
|
| 444 |
+
Includes: role description, exact Action JSON schema,
|
| 445 |
+
one example per action type. Ends with:
|
| 446 |
+
"Respond ONLY with valid JSON matching the Action schema. No explanation."
|
| 447 |
+
"""
|
| 448 |
+
return self.task.get_system_prompt()
|
env/data_generator.py
ADDED
|
@@ -0,0 +1,1118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data Generator for CodeReviewEnv
|
| 3 |
+
|
| 4 |
+
Generates realistic synthetic pull requests with actual code diffs.
|
| 5 |
+
The FIXED_TEST_SUITE provides 20 pre-generated PRs at seed=42 for
|
| 6 |
+
deterministic evaluation — all episodes draw from this fixed suite.
|
| 7 |
+
|
| 8 |
+
Bug categories and ground truth severity mapping:
|
| 9 |
+
sql_injection → critical
|
| 10 |
+
security_vulnerability → critical
|
| 11 |
+
race_condition → high
|
| 12 |
+
null_pointer → high
|
| 13 |
+
logic_error → medium
|
| 14 |
+
missing_error_handling → medium
|
| 15 |
+
performance_issue → low
|
| 16 |
+
style_only → none
|
| 17 |
+
|
| 18 |
+
Author experience affects bug probability:
|
| 19 |
+
junior: 70% chance of bug (weighted: null_pointer, missing_error_handling)
|
| 20 |
+
mid: 40% chance of bug (any category)
|
| 21 |
+
senior: 15% chance of bug (weighted: performance_issue, style_only)
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import random
|
| 25 |
+
from typing import List, Dict, Optional, Tuple
|
| 26 |
+
from env.models import PRFile, Observation
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ─── Bug category → ground truth severity ────────────────────────────────────
|
| 30 |
+
|
| 31 |
+
BUG_SEVERITY_MAP: Dict[str, str] = {
|
| 32 |
+
"sql_injection": "critical",
|
| 33 |
+
"security_vulnerability": "critical",
|
| 34 |
+
"race_condition": "high",
|
| 35 |
+
"null_pointer": "high",
|
| 36 |
+
"logic_error": "medium",
|
| 37 |
+
"missing_error_handling": "medium",
|
| 38 |
+
"performance_issue": "low",
|
| 39 |
+
"style_only": "none",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# Ordered severity levels for adjacency calculations
|
| 43 |
+
SEVERITY_ORDER: List[str] = ["critical", "high", "medium", "low", "none"]
|
| 44 |
+
|
| 45 |
+
# Keywords associated with each bug category — used by grader_hard for
|
| 46 |
+
# specificity scoring via keyword matching (no LLM calls)
|
| 47 |
+
BUG_KEYWORDS: Dict[str, List[str]] = {
|
| 48 |
+
"null_pointer": ["null", "None", "NullPointerException", "check", "guard", "nil"],
|
| 49 |
+
"sql_injection": ["injection", "parameterize", "prepared", "sanitize", "escape", "query"],
|
| 50 |
+
"race_condition": ["race", "lock", "mutex", "atomic", "thread-safe", "concurrent", "sync"],
|
| 51 |
+
"logic_error": ["condition", "branch", "edge case", "off-by-one", "boundary", "logic"],
|
| 52 |
+
"missing_error_handling": ["exception", "catch", "error", "handle", "try", "raise"],
|
| 53 |
+
"security_vulnerability": ["auth", "token", "encrypt", "hash", "expose", "secret", "leak"],
|
| 54 |
+
"performance_issue": ["O(n)", "complexity", "cache", "index", "query", "optimize", "loop"],
|
| 55 |
+
"style_only": ["naming", "format", "indent", "style", "convention"],
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# Actionability keywords — used by grader_hard to score whether comments
|
| 59 |
+
# contain concrete suggestions (empirically selected from code review corpora)
|
| 60 |
+
ACTIONABILITY_KEYWORDS: List[str] = [
|
| 61 |
+
"use", "replace", "add", "remove", "consider", "should", "instead",
|
| 62 |
+
"refactor", "extract", "avoid", "change", "move", "wrap",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ─── PR Templates ────────────────────────────────────────────────────────────
|
| 67 |
+
# Each template is a complete PR with realistic code diff, ground truth bug
|
| 68 |
+
# info, and pre-annotated human labels for reliability analysis.
|
| 69 |
+
|
| 70 |
+
PR_TEMPLATES: List[Dict] = [
|
| 71 |
+
# ── 1. Java null pointer ────────────────────────────────────────────
|
| 72 |
+
{
|
| 73 |
+
"pr_id": "PR-001",
|
| 74 |
+
"title": "Fix null pointer in UserService.java",
|
| 75 |
+
"description": "Refactored UserService to handle user lookup. Added caching for frequently accessed users.",
|
| 76 |
+
"author_experience": "junior",
|
| 77 |
+
"language": "java",
|
| 78 |
+
"filename": "src/main/java/com/app/UserService.java",
|
| 79 |
+
"diff": '''@@ -45,12 +45,18 @@ public class UserService {
|
| 80 |
+
+ public User getUserProfile(String userId) {
|
| 81 |
+
+ User user = userRepository.findById(userId);
|
| 82 |
+
+ // BUG: No null check — user could be null if not found
|
| 83 |
+
+ String displayName = user.getFirstName() + " " + user.getLastName();
|
| 84 |
+
+ user.setDisplayName(displayName);
|
| 85 |
+
+ cacheManager.put(userId, user);
|
| 86 |
+
+ return user;
|
| 87 |
+
+ }
|
| 88 |
+
+
|
| 89 |
+
+ public void updateUserEmail(String userId, String email) {
|
| 90 |
+
+ User user = userRepository.findById(userId);
|
| 91 |
+
+ user.setEmail(email); // BUG: same null pointer pattern
|
| 92 |
+
+ userRepository.save(user);
|
| 93 |
+
+ }''',
|
| 94 |
+
"lines_changed": 14,
|
| 95 |
+
"has_tests": False,
|
| 96 |
+
"bug_category": "null_pointer",
|
| 97 |
+
"ground_truth_severity": "high",
|
| 98 |
+
"bug_lines": [48, 56], # lines where bugs exist in the diff
|
| 99 |
+
"human_labels": ["high", "high", "high"],
|
| 100 |
+
"human_agreement": 1.0,
|
| 101 |
+
"cohen_kappa": 1.0,
|
| 102 |
+
},
|
| 103 |
+
# ── 2. Python missing error handling ────────────────────────────────
|
| 104 |
+
{
|
| 105 |
+
"pr_id": "PR-002",
|
| 106 |
+
"title": "Add rate limiting to /api/auth",
|
| 107 |
+
"description": "Implemented token bucket rate limiter for authentication endpoints to prevent brute force attacks.",
|
| 108 |
+
"author_experience": "mid",
|
| 109 |
+
"language": "python",
|
| 110 |
+
"filename": "api/auth/rate_limiter.py",
|
| 111 |
+
"diff": '''@@ -1,0 +1,28 @@
|
| 112 |
+
+import time
|
| 113 |
+
+from collections import defaultdict
|
| 114 |
+
+
|
| 115 |
+
+class RateLimiter:
|
| 116 |
+
+ def __init__(self, max_requests=100, window_seconds=60):
|
| 117 |
+
+ self.max_requests = max_requests
|
| 118 |
+
+ self.window_seconds = window_seconds
|
| 119 |
+
+ self.requests = defaultdict(list)
|
| 120 |
+
+
|
| 121 |
+
+ def is_allowed(self, client_ip):
|
| 122 |
+
+ now = time.time()
|
| 123 |
+
+ # BUG: No error handling if client_ip is None or empty
|
| 124 |
+
+ window_start = now - self.window_seconds
|
| 125 |
+
+ self.requests[client_ip] = [
|
| 126 |
+
+ t for t in self.requests[client_ip] if t > window_start
|
| 127 |
+
+ ]
|
| 128 |
+
+ if len(self.requests[client_ip]) >= self.max_requests:
|
| 129 |
+
+ return False
|
| 130 |
+
+ self.requests[client_ip].append(now)
|
| 131 |
+
+ return True
|
| 132 |
+
+
|
| 133 |
+
+ def get_remaining(self, client_ip):
|
| 134 |
+
+ # BUG: doesn't handle KeyError if client never made a request
|
| 135 |
+
+ count = len(self.requests[client_ip])
|
| 136 |
+
+ return max(0, self.max_requests - count)''',
|
| 137 |
+
"lines_changed": 25,
|
| 138 |
+
"has_tests": False,
|
| 139 |
+
"bug_category": "missing_error_handling",
|
| 140 |
+
"ground_truth_severity": "medium",
|
| 141 |
+
"bug_lines": [12, 23],
|
| 142 |
+
"human_labels": ["medium", "medium", "high"],
|
| 143 |
+
"human_agreement": 0.67,
|
| 144 |
+
"cohen_kappa": 0.72,
|
| 145 |
+
},
|
| 146 |
+
# ── 3. Python SQL injection ─────────────────────────────────────────
|
| 147 |
+
{
|
| 148 |
+
"pr_id": "PR-003",
|
| 149 |
+
"title": "Optimize database queries in ProductRepository",
|
| 150 |
+
"description": "Added search functionality with direct SQL for performance. Bypasses ORM overhead for complex queries.",
|
| 151 |
+
"author_experience": "senior",
|
| 152 |
+
"language": "python",
|
| 153 |
+
"filename": "repositories/product_repo.py",
|
| 154 |
+
"diff": '''@@ -32,6 +32,22 @@ class ProductRepository:
|
| 155 |
+
+ def search_products(self, query_text, category=None):
|
| 156 |
+
+ """Fast product search bypassing ORM for performance."""
|
| 157 |
+
+ # BUG: SQL injection — string interpolation in query
|
| 158 |
+
+ sql = f"SELECT * FROM products WHERE name LIKE '%{query_text}%'"
|
| 159 |
+
+ if category:
|
| 160 |
+
+ sql += f" AND category = '{category}'"
|
| 161 |
+
+ cursor = self.db.execute(sql)
|
| 162 |
+
+ return [dict(row) for row in cursor.fetchall()]
|
| 163 |
+
+
|
| 164 |
+
+ def bulk_update_prices(self, updates):
|
| 165 |
+
+ """Batch price update for efficiency."""
|
| 166 |
+
+ for product_id, new_price in updates:
|
| 167 |
+
+ # BUG: Another SQL injection vector
|
| 168 |
+
+ self.db.execute(
|
| 169 |
+
+ f"UPDATE products SET price = {new_price} WHERE id = '{product_id}'"
|
| 170 |
+
+ )
|
| 171 |
+
+ self.db.commit()''',
|
| 172 |
+
"lines_changed": 17,
|
| 173 |
+
"has_tests": True,
|
| 174 |
+
"bug_category": "sql_injection",
|
| 175 |
+
"ground_truth_severity": "critical",
|
| 176 |
+
"bug_lines": [36, 46],
|
| 177 |
+
"human_labels": ["critical", "critical", "critical"],
|
| 178 |
+
"human_agreement": 1.0,
|
| 179 |
+
"cohen_kappa": 1.0,
|
| 180 |
+
},
|
| 181 |
+
# ── 4. JavaScript SQL injection ─────────────────────────────────────
|
| 182 |
+
{
|
| 183 |
+
"pr_id": "PR-004",
|
| 184 |
+
"title": "Add user input validation",
|
| 185 |
+
"description": "Added server-side validation for user registration. Validates email format and password strength.",
|
| 186 |
+
"author_experience": "junior",
|
| 187 |
+
"language": "javascript",
|
| 188 |
+
"filename": "routes/users.js",
|
| 189 |
+
"diff": '''@@ -15,4 +15,26 @@ const express = require('express');
|
| 190 |
+
+router.post('/register', async (req, res) => {
|
| 191 |
+
+ const { username, email, password } = req.body;
|
| 192 |
+
+
|
| 193 |
+
+ // Validate email format
|
| 194 |
+
+ if (!email.includes('@')) {
|
| 195 |
+
+ return res.status(400).json({ error: 'Invalid email' });
|
| 196 |
+
+ }
|
| 197 |
+
+
|
| 198 |
+
+ // BUG: SQL injection — concatenating user input into query
|
| 199 |
+
+ const checkQuery = `SELECT * FROM users WHERE username = '${username}'`;
|
| 200 |
+
+ const existing = await db.query(checkQuery);
|
| 201 |
+
+
|
| 202 |
+
+ if (existing.rows.length > 0) {
|
| 203 |
+
+ return res.status(409).json({ error: 'Username taken' });
|
| 204 |
+
+ }
|
| 205 |
+
+
|
| 206 |
+
+ // BUG: Password stored in plaintext — no hashing
|
| 207 |
+
+ const insertQuery = `INSERT INTO users (username, email, password)
|
| 208 |
+
+ VALUES ('${username}', '${email}', '${password}')`;
|
| 209 |
+
+ await db.query(insertQuery);
|
| 210 |
+
+
|
| 211 |
+
+ res.status(201).json({ message: 'User created' });
|
| 212 |
+
+});''',
|
| 213 |
+
"lines_changed": 22,
|
| 214 |
+
"has_tests": False,
|
| 215 |
+
"bug_category": "sql_injection",
|
| 216 |
+
"ground_truth_severity": "critical",
|
| 217 |
+
"bug_lines": [24, 32],
|
| 218 |
+
"human_labels": ["critical", "critical", "critical"],
|
| 219 |
+
"human_agreement": 1.0,
|
| 220 |
+
"cohen_kappa": 1.0,
|
| 221 |
+
},
|
| 222 |
+
# ── 5. Go race condition ────────────────────────────────────────────
|
| 223 |
+
{
|
| 224 |
+
"pr_id": "PR-005",
|
| 225 |
+
"title": "Fix race condition in cache invalidation",
|
| 226 |
+
"description": "Updated cache invalidation to handle concurrent access patterns. Added TTL-based expiry.",
|
| 227 |
+
"author_experience": "mid",
|
| 228 |
+
"language": "go",
|
| 229 |
+
"filename": "pkg/cache/manager.go",
|
| 230 |
+
"diff": '''@@ -18,6 +18,30 @@ type CacheManager struct {
|
| 231 |
+
+func (cm *CacheManager) Get(key string) (interface{}, bool) {
|
| 232 |
+
+ // BUG: No mutex lock — concurrent reads/writes cause data race
|
| 233 |
+
+ entry, exists := cm.store[key]
|
| 234 |
+
+ if !exists {
|
| 235 |
+
+ return nil, false
|
| 236 |
+
+ }
|
| 237 |
+
+ if time.Now().After(entry.ExpiresAt) {
|
| 238 |
+
+ // BUG: Deleting without lock while other goroutines may read
|
| 239 |
+
+ delete(cm.store, key)
|
| 240 |
+
+ return nil, false
|
| 241 |
+
+ }
|
| 242 |
+
+ return entry.Value, true
|
| 243 |
+
+}
|
| 244 |
+
+
|
| 245 |
+
+func (cm *CacheManager) Set(key string, value interface{}, ttl time.Duration) {
|
| 246 |
+
+ // BUG: No mutex lock on write — race with Get/Delete
|
| 247 |
+
+ cm.store[key] = CacheEntry{
|
| 248 |
+
+ Value: value,
|
| 249 |
+
+ ExpiresAt: time.Now().Add(ttl),
|
| 250 |
+
+ }
|
| 251 |
+
+}
|
| 252 |
+
+
|
| 253 |
+
+func (cm *CacheManager) Delete(key string) {
|
| 254 |
+
+ delete(cm.store, key)
|
| 255 |
+
+}''',
|
| 256 |
+
"lines_changed": 24,
|
| 257 |
+
"has_tests": True,
|
| 258 |
+
"bug_category": "race_condition",
|
| 259 |
+
"ground_truth_severity": "high",
|
| 260 |
+
"bug_lines": [20, 27, 34],
|
| 261 |
+
"human_labels": ["high", "high", "critical"],
|
| 262 |
+
"human_agreement": 0.67,
|
| 263 |
+
"cohen_kappa": 0.65,
|
| 264 |
+
},
|
| 265 |
+
# ── 6. Python security vulnerability ────────────────────────────────
|
| 266 |
+
{
|
| 267 |
+
"pr_id": "PR-006",
|
| 268 |
+
"title": "Refactor authentication middleware",
|
| 269 |
+
"description": "Simplified auth middleware and added JWT token verification. Moved secret key to config.",
|
| 270 |
+
"author_experience": "senior",
|
| 271 |
+
"language": "python",
|
| 272 |
+
"filename": "middleware/auth.py",
|
| 273 |
+
"diff": '''@@ -5,8 +5,28 @@ from functools import wraps
|
| 274 |
+
+import jwt
|
| 275 |
+
+import os
|
| 276 |
+
+
|
| 277 |
+
+# BUG: Hardcoded secret key — should use env var or vault
|
| 278 |
+
+SECRET_KEY = "super_secret_key_12345"
|
| 279 |
+
+
|
| 280 |
+
+def verify_token(token):
|
| 281 |
+
+ """Verify JWT token and return payload."""
|
| 282 |
+
+ try:
|
| 283 |
+
+ payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
| 284 |
+
+ return payload
|
| 285 |
+
+ except jwt.ExpiredSignatureError:
|
| 286 |
+
+ return None
|
| 287 |
+
+ # BUG: Missing InvalidTokenError handling — crashes on malformed tokens
|
| 288 |
+
+
|
| 289 |
+
+def require_auth(f):
|
| 290 |
+
+ @wraps(f)
|
| 291 |
+
+ def decorated(*args, **kwargs):
|
| 292 |
+
+ token = request.headers.get("Authorization", "").replace("Bearer ", "")
|
| 293 |
+
+ # BUG: Token value exposed in debug log
|
| 294 |
+
+ print(f"DEBUG: verifying token {token}")
|
| 295 |
+
+ payload = verify_token(token)
|
| 296 |
+
+ if not payload:
|
| 297 |
+
+ return jsonify({"error": "Unauthorized"}), 401
|
| 298 |
+
+ return f(*args, **kwargs)
|
| 299 |
+
+ return decorated''',
|
| 300 |
+
"lines_changed": 23,
|
| 301 |
+
"has_tests": False,
|
| 302 |
+
"bug_category": "security_vulnerability",
|
| 303 |
+
"ground_truth_severity": "critical",
|
| 304 |
+
"bug_lines": [9, 19, 25],
|
| 305 |
+
"human_labels": ["critical", "critical", "critical"],
|
| 306 |
+
"human_agreement": 1.0,
|
| 307 |
+
"cohen_kappa": 1.0,
|
| 308 |
+
},
|
| 309 |
+
# ── 7. JavaScript logic error ───────────────────────────────────────
|
| 310 |
+
{
|
| 311 |
+
"pr_id": "PR-007",
|
| 312 |
+
"title": "Add pagination to list endpoints",
|
| 313 |
+
"description": "Implemented cursor-based pagination for all list endpoints. Added page_size parameter.",
|
| 314 |
+
"author_experience": "mid",
|
| 315 |
+
"language": "javascript",
|
| 316 |
+
"filename": "controllers/listController.js",
|
| 317 |
+
"diff": '''@@ -10,4 +10,28 @@ const { Op } = require('sequelize');
|
| 318 |
+
+async function listItems(req, res) {
|
| 319 |
+
+ const page = parseInt(req.query.page) || 1;
|
| 320 |
+
+ const pageSize = parseInt(req.query.page_size) || 20;
|
| 321 |
+
+
|
| 322 |
+
+ // BUG: Off-by-one error — first page skips first item
|
| 323 |
+
+ const offset = page * pageSize;
|
| 324 |
+
+ // Should be: (page - 1) * pageSize
|
| 325 |
+
+
|
| 326 |
+
+ const { count, rows } = await Item.findAndCountAll({
|
| 327 |
+
+ limit: pageSize,
|
| 328 |
+
+ offset: offset,
|
| 329 |
+
+ order: [['createdAt', 'DESC']],
|
| 330 |
+
+ });
|
| 331 |
+
+
|
| 332 |
+
+ // BUG: Total pages calculation wrong for exact multiples
|
| 333 |
+
+ const totalPages = Math.floor(count / pageSize);
|
| 334 |
+
+ // Should be: Math.ceil(count / pageSize)
|
| 335 |
+
+
|
| 336 |
+
+ res.json({
|
| 337 |
+
+ items: rows,
|
| 338 |
+
+ pagination: {
|
| 339 |
+
+ page,
|
| 340 |
+
+ pageSize,
|
| 341 |
+
+ totalPages,
|
| 342 |
+
+ totalItems: count,
|
| 343 |
+
+ },
|
| 344 |
+
+ });
|
| 345 |
+
+}''',
|
| 346 |
+
"lines_changed": 24,
|
| 347 |
+
"has_tests": True,
|
| 348 |
+
"bug_category": "logic_error",
|
| 349 |
+
"ground_truth_severity": "medium",
|
| 350 |
+
"bug_lines": [15, 24],
|
| 351 |
+
"human_labels": ["medium", "medium", "medium"],
|
| 352 |
+
"human_agreement": 1.0,
|
| 353 |
+
"cohen_kappa": 1.0,
|
| 354 |
+
},
|
| 355 |
+
# ── 8. Python style only ────────────────────────────────────────────
|
| 356 |
+
{
|
| 357 |
+
"pr_id": "PR-008",
|
| 358 |
+
"title": "Update README formatting",
|
| 359 |
+
"description": "Cleaned up README formatting, fixed markdown tables, and updated badge URLs.",
|
| 360 |
+
"author_experience": "senior",
|
| 361 |
+
"language": "python",
|
| 362 |
+
"filename": "utils/formatter.py",
|
| 363 |
+
"diff": '''@@ -1,15 +1,15 @@
|
| 364 |
+
-def formatUserName(firstName, lastName):
|
| 365 |
+
- """format the user name"""
|
| 366 |
+
- FullName = firstName + " " + lastName
|
| 367 |
+
- return FullName
|
| 368 |
+
+def format_user_name(first_name, last_name):
|
| 369 |
+
+ """Format the user's display name from first and last name components."""
|
| 370 |
+
+ full_name = first_name + " " + last_name
|
| 371 |
+
+ return full_name
|
| 372 |
+
|
| 373 |
+
-def getUserAge(birthYear):
|
| 374 |
+
- import datetime
|
| 375 |
+
- currentYear = datetime.datetime.now().year
|
| 376 |
+
- AGE = currentYear - birthYear
|
| 377 |
+
- return AGE
|
| 378 |
+
+def get_user_age(birth_year):
|
| 379 |
+
+ """Calculate user age from birth year."""
|
| 380 |
+
+ import datetime
|
| 381 |
+
+ current_year = datetime.datetime.now().year
|
| 382 |
+
+ age = current_year - birth_year
|
| 383 |
+
+ return age''',
|
| 384 |
+
"lines_changed": 12,
|
| 385 |
+
"has_tests": True,
|
| 386 |
+
"bug_category": "style_only",
|
| 387 |
+
"ground_truth_severity": "none",
|
| 388 |
+
"bug_lines": [],
|
| 389 |
+
"human_labels": ["none", "none", "none"],
|
| 390 |
+
"human_agreement": 1.0,
|
| 391 |
+
"cohen_kappa": 1.0,
|
| 392 |
+
},
|
| 393 |
+
# ── 9. Java missing error handling ──────────────────────────────────
|
| 394 |
+
{
|
| 395 |
+
"pr_id": "PR-009",
|
| 396 |
+
"title": "Add file upload endpoint",
|
| 397 |
+
"description": "New endpoint for uploading user profile images. Supports JPEG and PNG up to 5MB.",
|
| 398 |
+
"author_experience": "junior",
|
| 399 |
+
"language": "java",
|
| 400 |
+
"filename": "src/main/java/com/app/FileUploadController.java",
|
| 401 |
+
"diff": '''@@ -20,6 +20,30 @@ public class FileUploadController {
|
| 402 |
+
+ @PostMapping("/upload")
|
| 403 |
+
+ public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
|
| 404 |
+
+ String filename = file.getOriginalFilename();
|
| 405 |
+
+ // BUG: No file type validation — accepts any file type
|
| 406 |
+
+ // BUG: No file size check despite 5MB limit in description
|
| 407 |
+
+
|
| 408 |
+
+ String uploadDir = "/uploads/" + filename;
|
| 409 |
+
+ // BUG: Path traversal vulnerability — filename could contain ../
|
| 410 |
+
+
|
| 411 |
+
+ try {
|
| 412 |
+
+ File dest = new File(uploadDir);
|
| 413 |
+
+ file.transferTo(dest);
|
| 414 |
+
+ } catch (IOException e) {
|
| 415 |
+
+ // BUG: Swallows exception — returns 200 even on failure
|
| 416 |
+
+ System.out.println("Upload failed");
|
| 417 |
+
+ }
|
| 418 |
+
+
|
| 419 |
+
+ return ResponseEntity.ok("File uploaded: " + filename);
|
| 420 |
+
+ }
|
| 421 |
+
+
|
| 422 |
+
+ @GetMapping("/files/{filename}")
|
| 423 |
+
+ public byte[] getFile(@PathVariable String filename) {
|
| 424 |
+
+ // BUG: No error handling if file doesn't exist
|
| 425 |
+
+ return Files.readAllBytes(Paths.get("/uploads/" + filename));
|
| 426 |
+
+ }''',
|
| 427 |
+
"lines_changed": 22,
|
| 428 |
+
"has_tests": False,
|
| 429 |
+
"bug_category": "missing_error_handling",
|
| 430 |
+
"ground_truth_severity": "medium",
|
| 431 |
+
"bug_lines": [23, 24, 27, 34, 43],
|
| 432 |
+
"human_labels": ["high", "medium", "medium"],
|
| 433 |
+
"human_agreement": 0.67,
|
| 434 |
+
"cohen_kappa": 0.61,
|
| 435 |
+
},
|
| 436 |
+
# ── 10. Go performance issue ────────────────────────────────────────
|
| 437 |
+
{
|
| 438 |
+
"pr_id": "PR-010",
|
| 439 |
+
"title": "Add metrics aggregation endpoint",
|
| 440 |
+
"description": "New endpoint to aggregate user activity metrics. Computes daily, weekly, monthly summaries.",
|
| 441 |
+
"author_experience": "senior",
|
| 442 |
+
"language": "go",
|
| 443 |
+
"filename": "pkg/metrics/aggregator.go",
|
| 444 |
+
"diff": '''@@ -12,4 +12,30 @@ type MetricsAggregator struct {
|
| 445 |
+
+func (ma *MetricsAggregator) ComputeDailySummary(userID string) (*Summary, error) {
|
| 446 |
+
+ // BUG: O(n) scan of entire events table — no index usage, no date filter
|
| 447 |
+
+ events, err := ma.db.Query("SELECT * FROM events WHERE user_id = $1", userID)
|
| 448 |
+
+ if err != nil {
|
| 449 |
+
+ return nil, err
|
| 450 |
+
+ }
|
| 451 |
+
+
|
| 452 |
+
+ summary := &Summary{}
|
| 453 |
+
+ for events.Next() {
|
| 454 |
+
+ var e Event
|
| 455 |
+
+ events.Scan(&e.ID, &e.Type, &e.Timestamp, &e.UserID, &e.Data)
|
| 456 |
+
+
|
| 457 |
+
+ // BUG: Parsing timestamp in tight loop — should pre-compute
|
| 458 |
+
+ t, _ := time.Parse(time.RFC3339, e.Timestamp)
|
| 459 |
+
+ if t.Day() == time.Now().Day() {
|
| 460 |
+
+ summary.Count++
|
| 461 |
+
+ summary.TotalDuration += e.Duration
|
| 462 |
+
+ }
|
| 463 |
+
+ }
|
| 464 |
+
+
|
| 465 |
+
+ // BUG: N+1 query — fetches user details for each event separately
|
| 466 |
+
+ for i, e := range summary.Events {
|
| 467 |
+
+ user, _ := ma.db.QueryRow("SELECT name FROM users WHERE id = $1", e.UserID)
|
| 468 |
+
+ summary.Events[i].UserName = user.Name
|
| 469 |
+
+ }
|
| 470 |
+
+
|
| 471 |
+
+ return summary, nil
|
| 472 |
+
+}''',
|
| 473 |
+
"lines_changed": 26,
|
| 474 |
+
"has_tests": True,
|
| 475 |
+
"bug_category": "performance_issue",
|
| 476 |
+
"ground_truth_severity": "low",
|
| 477 |
+
"bug_lines": [14, 25, 32],
|
| 478 |
+
"human_labels": ["low", "low", "medium"],
|
| 479 |
+
"human_agreement": 0.67,
|
| 480 |
+
"cohen_kappa": 0.68,
|
| 481 |
+
},
|
| 482 |
+
# ── 11. Python null pointer ──────────────────────────��──────────────
|
| 483 |
+
{
|
| 484 |
+
"pr_id": "PR-011",
|
| 485 |
+
"title": "Add webhook notification handler",
|
| 486 |
+
"description": "Handles incoming webhooks from payment provider. Processes payment success and failure events.",
|
| 487 |
+
"author_experience": "junior",
|
| 488 |
+
"language": "python",
|
| 489 |
+
"filename": "handlers/webhook.py",
|
| 490 |
+
"diff": '''@@ -1,0 +1,28 @@
|
| 491 |
+
+import json
|
| 492 |
+
+from flask import request, jsonify
|
| 493 |
+
+
|
| 494 |
+
+def handle_webhook():
|
| 495 |
+
+ payload = request.get_json()
|
| 496 |
+
+
|
| 497 |
+
+ # BUG: No null check — payload could be None if body isn't JSON
|
| 498 |
+
+ event_type = payload["event_type"]
|
| 499 |
+
+ transaction_id = payload["data"]["transaction_id"]
|
| 500 |
+
+
|
| 501 |
+
+ # BUG: No check if "data" key exists
|
| 502 |
+
+ amount = payload["data"]["amount"]
|
| 503 |
+
+ customer = payload["data"]["customer"]
|
| 504 |
+
+
|
| 505 |
+
+ if event_type == "payment_success":
|
| 506 |
+
+ # BUG: customer["email"] could be None
|
| 507 |
+
+ send_receipt(customer["email"], amount, transaction_id)
|
| 508 |
+
+ elif event_type == "payment_failed":
|
| 509 |
+
+ notify_support(transaction_id)
|
| 510 |
+
+
|
| 511 |
+
+ return jsonify({"status": "processed"}), 200
|
| 512 |
+
+
|
| 513 |
+
+def send_receipt(email, amount, txn_id):
|
| 514 |
+
+ """Send payment receipt email."""
|
| 515 |
+
+ msg = f"Payment of ${amount} received. Transaction: {txn_id}"
|
| 516 |
+
+ # email sending logic
|
| 517 |
+
+ print(f"Sending receipt to {email}: {msg}")''',
|
| 518 |
+
"lines_changed": 27,
|
| 519 |
+
"has_tests": False,
|
| 520 |
+
"bug_category": "null_pointer",
|
| 521 |
+
"ground_truth_severity": "high",
|
| 522 |
+
"bug_lines": [8, 11, 17],
|
| 523 |
+
"human_labels": ["high", "high", "medium"],
|
| 524 |
+
"human_agreement": 0.67,
|
| 525 |
+
"cohen_kappa": 0.72,
|
| 526 |
+
},
|
| 527 |
+
# ── 12. JavaScript security vulnerability ───────────────────────────
|
| 528 |
+
{
|
| 529 |
+
"pr_id": "PR-012",
|
| 530 |
+
"title": "Add session management",
|
| 531 |
+
"description": "Implemented user session handling with cookie-based tokens. Added remember me functionality.",
|
| 532 |
+
"author_experience": "junior",
|
| 533 |
+
"language": "javascript",
|
| 534 |
+
"filename": "middleware/session.js",
|
| 535 |
+
"diff": '''@@ -1,0 +1,30 @@
|
| 536 |
+
+const crypto = require('crypto');
|
| 537 |
+
+
|
| 538 |
+
+// BUG: Weak secret — predictable session tokens
|
| 539 |
+
+const SESSION_SECRET = 'mysecret123';
|
| 540 |
+
+
|
| 541 |
+
+function createSession(userId) {
|
| 542 |
+
+ // BUG: Using MD5 — cryptographically broken hash function
|
| 543 |
+
+ const token = crypto.createHash('md5')
|
| 544 |
+
+ .update(userId + SESSION_SECRET + Date.now())
|
| 545 |
+
+ .digest('hex');
|
| 546 |
+
+
|
| 547 |
+
+ return {
|
| 548 |
+
+ token,
|
| 549 |
+
+ userId,
|
| 550 |
+
+ // BUG: No expiry set — sessions live forever
|
| 551 |
+
+ createdAt: new Date().toISOString(),
|
| 552 |
+
+ };
|
| 553 |
+
+}
|
| 554 |
+
+
|
| 555 |
+
+function validateSession(token) {
|
| 556 |
+
+ // BUG: Timing attack vulnerability — string comparison
|
| 557 |
+
+ const session = sessions.find(s => s.token === token);
|
| 558 |
+
+ if (!session) return null;
|
| 559 |
+
+
|
| 560 |
+
+ // BUG: No check for session expiry
|
| 561 |
+
+ return session.userId;
|
| 562 |
+
+}
|
| 563 |
+
+
|
| 564 |
+
+const sessions = [];
|
| 565 |
+
+module.exports = { createSession, validateSession };''',
|
| 566 |
+
"lines_changed": 29,
|
| 567 |
+
"has_tests": False,
|
| 568 |
+
"bug_category": "security_vulnerability",
|
| 569 |
+
"ground_truth_severity": "critical",
|
| 570 |
+
"bug_lines": [4, 8, 16, 22, 26],
|
| 571 |
+
"human_labels": ["critical", "critical", "high"],
|
| 572 |
+
"human_agreement": 0.67,
|
| 573 |
+
"cohen_kappa": 0.78,
|
| 574 |
+
},
|
| 575 |
+
# ── 13. Python logic error ──────────────────────────────────────────
|
| 576 |
+
{
|
| 577 |
+
"pr_id": "PR-013",
|
| 578 |
+
"title": "Implement discount calculation engine",
|
| 579 |
+
"description": "New pricing engine with tiered discounts. Supports percentage and fixed amount discounts.",
|
| 580 |
+
"author_experience": "mid",
|
| 581 |
+
"language": "python",
|
| 582 |
+
"filename": "pricing/discount_engine.py",
|
| 583 |
+
"diff": '''@@ -1,0 +1,32 @@
|
| 584 |
+
+class DiscountEngine:
|
| 585 |
+
+ TIER_THRESHOLDS = {
|
| 586 |
+
+ "bronze": 0,
|
| 587 |
+
+ "silver": 1000,
|
| 588 |
+
+ "gold": 5000,
|
| 589 |
+
+ "platinum": 10000,
|
| 590 |
+
+ }
|
| 591 |
+
+
|
| 592 |
+
+ def calculate_discount(self, order_total, customer_tier, coupon_code=None):
|
| 593 |
+
+ discount = 0.0
|
| 594 |
+
+
|
| 595 |
+
+ # Tier-based discount
|
| 596 |
+
+ tier_rates = {"bronze": 0.0, "silver": 0.05, "gold": 0.10, "platinum": 0.15}
|
| 597 |
+
+ # BUG: Missing KeyError handling for unknown tier
|
| 598 |
+
+ discount += order_total * tier_rates[customer_tier]
|
| 599 |
+
+
|
| 600 |
+
+ # Coupon discount
|
| 601 |
+
+ if coupon_code:
|
| 602 |
+
+ coupon_discount = self._lookup_coupon(coupon_code)
|
| 603 |
+
+ # BUG: Discounts stack without cap — can exceed order total
|
| 604 |
+
+ discount += coupon_discount
|
| 605 |
+
+
|
| 606 |
+
+ # BUG: Off-by-one in boundary check — gold customers at exactly 5000 get silver rate
|
| 607 |
+
+ if order_total >= self.TIER_THRESHOLDS.get(customer_tier, 0):
|
| 608 |
+
+ discount *= 1.0 # threshold met, keep discount
|
| 609 |
+
+ else:
|
| 610 |
+
+ discount *= 0.5 # below threshold, halve it
|
| 611 |
+
+
|
| 612 |
+
+ return discount
|
| 613 |
+
+
|
| 614 |
+
+ def _lookup_coupon(self, code):
|
| 615 |
+
+ coupons = {"SAVE10": 10.0, "SAVE20": 20.0, "HALF50": 50.0}
|
| 616 |
+
+ return coupons.get(code, 0.0)''',
|
| 617 |
+
"lines_changed": 32,
|
| 618 |
+
"has_tests": True,
|
| 619 |
+
"bug_category": "logic_error",
|
| 620 |
+
"ground_truth_severity": "medium",
|
| 621 |
+
"bug_lines": [15, 21, 24],
|
| 622 |
+
"human_labels": ["medium", "medium", "high"],
|
| 623 |
+
"human_agreement": 0.67,
|
| 624 |
+
"cohen_kappa": 0.72,
|
| 625 |
+
},
|
| 626 |
+
# ── 14. Go null pointer ─────────────────────────────────────────────
|
| 627 |
+
{
|
| 628 |
+
"pr_id": "PR-014",
|
| 629 |
+
"title": "Add gRPC health check service",
|
| 630 |
+
"description": "Implemented standard gRPC health check protocol for k8s liveness and readiness probes.",
|
| 631 |
+
"author_experience": "mid",
|
| 632 |
+
"language": "go",
|
| 633 |
+
"filename": "pkg/health/checker.go",
|
| 634 |
+
"diff": '''@@ -10,4 +10,28 @@ type HealthChecker struct {
|
| 635 |
+
+func (hc *HealthChecker) Check(ctx context.Context, req *pb.HealthCheckRequest) (*pb.HealthCheckResponse, error) {
|
| 636 |
+
+ service := req.GetService()
|
| 637 |
+
+
|
| 638 |
+
+ // BUG: No nil check on dependency map lookup
|
| 639 |
+
+ dep := hc.dependencies[service]
|
| 640 |
+
+ // dep could be nil if service name not registered
|
| 641 |
+
+
|
| 642 |
+
+ status := dep.Status() // BUG: nil pointer dereference if dep is nil
|
| 643 |
+
+
|
| 644 |
+
+ response := &pb.HealthCheckResponse{
|
| 645 |
+
+ Status: status,
|
| 646 |
+
+ }
|
| 647 |
+
+
|
| 648 |
+
+ // Check sub-dependencies
|
| 649 |
+
+ for _, subDep := range dep.SubDependencies {
|
| 650 |
+
+ // BUG: No nil check on subDep
|
| 651 |
+
+ subStatus := subDep.Status()
|
| 652 |
+
+ if subStatus != pb.HealthCheckResponse_SERVING {
|
| 653 |
+
+ response.Status = pb.HealthCheckResponse_NOT_SERVING
|
| 654 |
+
+ }
|
| 655 |
+
+ }
|
| 656 |
+
+
|
| 657 |
+
+ return response, nil
|
| 658 |
+
+}''',
|
| 659 |
+
"lines_changed": 22,
|
| 660 |
+
"has_tests": False,
|
| 661 |
+
"bug_category": "null_pointer",
|
| 662 |
+
"ground_truth_severity": "high",
|
| 663 |
+
"bug_lines": [15, 18, 27],
|
| 664 |
+
"human_labels": ["high", "high", "high"],
|
| 665 |
+
"human_agreement": 1.0,
|
| 666 |
+
"cohen_kappa": 1.0,
|
| 667 |
+
},
|
| 668 |
+
# ── 15. Python performance issue ────────────────────────────────────
|
| 669 |
+
{
|
| 670 |
+
"pr_id": "PR-015",
|
| 671 |
+
"title": "Add report generation module",
|
| 672 |
+
"description": "Generates PDF reports for quarterly analytics. Aggregates data from multiple tables.",
|
| 673 |
+
"author_experience": "senior",
|
| 674 |
+
"language": "python",
|
| 675 |
+
"filename": "reports/generator.py",
|
| 676 |
+
"diff": '''@@ -8,4 +8,30 @@ class ReportGenerator:
|
| 677 |
+
+ def generate_quarterly_report(self, quarter, year):
|
| 678 |
+
+ """Generate full quarterly report with graphics and tables."""
|
| 679 |
+
+ users = self.db.execute("SELECT * FROM users").fetchall()
|
| 680 |
+
+
|
| 681 |
+
+ report_data = []
|
| 682 |
+
+ for user in users:
|
| 683 |
+
+ # BUG: N+1 query — individual query per user in loop
|
| 684 |
+
+ orders = self.db.execute(
|
| 685 |
+
+ f"SELECT * FROM orders WHERE user_id = {user['id']}"
|
| 686 |
+
+ ).fetchall()
|
| 687 |
+
+
|
| 688 |
+
+ total = 0
|
| 689 |
+
+ for order in orders:
|
| 690 |
+
+ # BUG: Loading all order items just to sum — could use SQL SUM
|
| 691 |
+
+ items = self.db.execute(
|
| 692 |
+
+ f"SELECT * FROM order_items WHERE order_id = {order['id']}"
|
| 693 |
+
+ ).fetchall()
|
| 694 |
+
+ total += sum(item['price'] * item['quantity'] for item in items)
|
| 695 |
+
+
|
| 696 |
+
+ report_data.append({
|
| 697 |
+
+ "user": user['name'],
|
| 698 |
+
+ "total_spend": total,
|
| 699 |
+
+ "order_count": len(orders),
|
| 700 |
+
+ })
|
| 701 |
+
+
|
| 702 |
+
+ # BUG: Sorting entire list in memory instead of ORDER BY in SQL
|
| 703 |
+
+ report_data.sort(key=lambda x: x['total_spend'], reverse=True)
|
| 704 |
+
+
|
| 705 |
+
+ return report_data''',
|
| 706 |
+
"lines_changed": 28,
|
| 707 |
+
"has_tests": True,
|
| 708 |
+
"bug_category": "performance_issue",
|
| 709 |
+
"ground_truth_severity": "low",
|
| 710 |
+
"bug_lines": [16, 22, 34],
|
| 711 |
+
"human_labels": ["low", "medium", "low"],
|
| 712 |
+
"human_agreement": 0.67,
|
| 713 |
+
"cohen_kappa": 0.68,
|
| 714 |
+
},
|
| 715 |
+
# ── 16. Java race condition ─────────────────────────────────────────
|
| 716 |
+
{
|
| 717 |
+
"pr_id": "PR-016",
|
| 718 |
+
"title": "Implement connection pool manager",
|
| 719 |
+
"description": "Custom connection pool for database connections. Supports max connections and connection reuse.",
|
| 720 |
+
"author_experience": "mid",
|
| 721 |
+
"language": "java",
|
| 722 |
+
"filename": "src/main/java/com/app/ConnectionPool.java",
|
| 723 |
+
"diff": '''@@ -15,6 +15,32 @@ public class ConnectionPool {
|
| 724 |
+
+ private List<Connection> available = new ArrayList<>();
|
| 725 |
+
+ private List<Connection> inUse = new ArrayList<>();
|
| 726 |
+
+ private int maxConnections = 10;
|
| 727 |
+
+
|
| 728 |
+
+ public Connection getConnection() {
|
| 729 |
+
+ // BUG: No synchronization — multiple threads can get same connection
|
| 730 |
+
+ if (available.isEmpty()) {
|
| 731 |
+
+ if (inUse.size() < maxConnections) {
|
| 732 |
+
+ Connection conn = createConnection();
|
| 733 |
+
+ inUse.add(conn);
|
| 734 |
+
+ return conn;
|
| 735 |
+
+ }
|
| 736 |
+
+ // BUG: Busy wait without backoff — CPU spin
|
| 737 |
+
+ while (available.isEmpty()) {
|
| 738 |
+
+ // spin
|
| 739 |
+
+ }
|
| 740 |
+
+ }
|
| 741 |
+
+ // BUG: Race condition — another thread could take last connection
|
| 742 |
+
+ Connection conn = available.remove(0);
|
| 743 |
+
+ inUse.add(conn);
|
| 744 |
+
+ return conn;
|
| 745 |
+
+ }
|
| 746 |
+
+
|
| 747 |
+
+ public void releaseConnection(Connection conn) {
|
| 748 |
+
+ // BUG: No validation that conn is actually from this pool
|
| 749 |
+
+ inUse.remove(conn);
|
| 750 |
+
+ available.add(conn);
|
| 751 |
+
+ }''',
|
| 752 |
+
"lines_changed": 26,
|
| 753 |
+
"has_tests": False,
|
| 754 |
+
"bug_category": "race_condition",
|
| 755 |
+
"ground_truth_severity": "high",
|
| 756 |
+
"bug_lines": [21, 28, 33, 39],
|
| 757 |
+
"human_labels": ["high", "critical", "high"],
|
| 758 |
+
"human_agreement": 0.67,
|
| 759 |
+
"cohen_kappa": 0.65,
|
| 760 |
+
},
|
| 761 |
+
# ── 17. JavaScript missing error handling ───────────────────────────
|
| 762 |
+
{
|
| 763 |
+
"pr_id": "PR-017",
|
| 764 |
+
"title": "Add WebSocket chat handler",
|
| 765 |
+
"description": "Real-time chat implementation using WebSocket. Supports direct messages and group channels.",
|
| 766 |
+
"author_experience": "junior",
|
| 767 |
+
"language": "javascript",
|
| 768 |
+
"filename": "handlers/chat.js",
|
| 769 |
+
"diff": '''@@ -1,0 +1,30 @@
|
| 770 |
+
+const WebSocket = require('ws');
|
| 771 |
+
+
|
| 772 |
+
+function handleConnection(ws) {
|
| 773 |
+
+ ws.on('message', (data) => {
|
| 774 |
+
+ // BUG: No try-catch — invalid JSON crashes the server
|
| 775 |
+
+ const message = JSON.parse(data);
|
| 776 |
+
+
|
| 777 |
+
+ // BUG: No validation of message.type
|
| 778 |
+
+ if (message.type === 'direct') {
|
| 779 |
+
+ // BUG: No check if recipient exists
|
| 780 |
+
+ const recipient = connectedUsers[message.to];
|
| 781 |
+
+ recipient.send(JSON.stringify({
|
| 782 |
+
+ from: message.from,
|
| 783 |
+
+ text: message.text,
|
| 784 |
+
+ timestamp: new Date().toISOString(),
|
| 785 |
+
+ }));
|
| 786 |
+
+ } else if (message.type === 'channel') {
|
| 787 |
+
+ // BUG: No check if channel exists
|
| 788 |
+
+ channels[message.channel].forEach(user => {
|
| 789 |
+
+ user.send(JSON.stringify(message));
|
| 790 |
+
+ });
|
| 791 |
+
+ }
|
| 792 |
+
+ });
|
| 793 |
+
+
|
| 794 |
+
+ ws.on('close', () => {
|
| 795 |
+
+ // BUG: No cleanup of user from connectedUsers map
|
| 796 |
+
+ console.log('Client disconnected');
|
| 797 |
+
+ });
|
| 798 |
+
+}
|
| 799 |
+
+
|
| 800 |
+
+const connectedUsers = {};''',
|
| 801 |
+
"lines_changed": 30,
|
| 802 |
+
"has_tests": False,
|
| 803 |
+
"bug_category": "missing_error_handling",
|
| 804 |
+
"ground_truth_severity": "medium",
|
| 805 |
+
"bug_lines": [6, 8, 11, 19, 27],
|
| 806 |
+
"human_labels": ["medium", "high", "medium"],
|
| 807 |
+
"human_agreement": 0.67,
|
| 808 |
+
"cohen_kappa": 0.61,
|
| 809 |
+
},
|
| 810 |
+
# ── 18. Python security vulnerability ───────────────────────────────
|
| 811 |
+
{
|
| 812 |
+
"pr_id": "PR-018",
|
| 813 |
+
"title": "Add admin API key management",
|
| 814 |
+
"description": "Admin panel for managing API keys. Allows creation, revocation, and listing of keys.",
|
| 815 |
+
"author_experience": "junior",
|
| 816 |
+
"language": "python",
|
| 817 |
+
"filename": "admin/api_keys.py",
|
| 818 |
+
"diff": '''@@ -1,0 +1,32 @@
|
| 819 |
+
+import hashlib
|
| 820 |
+
+import os
|
| 821 |
+
+from datetime import datetime
|
| 822 |
+
+
|
| 823 |
+
+class APIKeyManager:
|
| 824 |
+
+ def __init__(self):
|
| 825 |
+
+ self.keys = {}
|
| 826 |
+
+
|
| 827 |
+
+ def create_key(self, user_id, permissions):
|
| 828 |
+
+ # BUG: Using MD5 for key generation — weak hash
|
| 829 |
+
+ raw_key = hashlib.md5(
|
| 830 |
+
+ f"{user_id}{datetime.now()}".encode()
|
| 831 |
+
+ ).hexdigest()
|
| 832 |
+
+
|
| 833 |
+
+ # BUG: Storing key in plaintext — should store hash only
|
| 834 |
+
+ self.keys[raw_key] = {
|
| 835 |
+
+ "user_id": user_id,
|
| 836 |
+
+ "permissions": permissions,
|
| 837 |
+
+ "created_at": datetime.now().isoformat(),
|
| 838 |
+
+ "key_plaintext": raw_key, # BUG: Storing plaintext key
|
| 839 |
+
+ }
|
| 840 |
+
+
|
| 841 |
+
+ return raw_key
|
| 842 |
+
+
|
| 843 |
+
+ def validate_key(self, key):
|
| 844 |
+
+ # BUG: No rate limiting on key validation — brute force possible
|
| 845 |
+
+ return key in self.keys
|
| 846 |
+
+
|
| 847 |
+
+ def list_keys(self, user_id):
|
| 848 |
+
+ # BUG: Returns full key data including plaintext — information leak
|
| 849 |
+
+ return [v for v in self.keys.values() if v["user_id"] == user_id]''',
|
| 850 |
+
"lines_changed": 31,
|
| 851 |
+
"has_tests": False,
|
| 852 |
+
"bug_category": "security_vulnerability",
|
| 853 |
+
"ground_truth_severity": "critical",
|
| 854 |
+
"bug_lines": [11, 16, 21, 27, 31],
|
| 855 |
+
"human_labels": ["critical", "critical", "critical"],
|
| 856 |
+
"human_agreement": 1.0,
|
| 857 |
+
"cohen_kappa": 1.0,
|
| 858 |
+
},
|
| 859 |
+
# ── 19. Go logic error ──────────────────────────────────────────────
|
| 860 |
+
{
|
| 861 |
+
"pr_id": "PR-019",
|
| 862 |
+
"title": "Implement retry mechanism with backoff",
|
| 863 |
+
"description": "Added exponential backoff retry for external API calls. Configurable max retries and base delay.",
|
| 864 |
+
"author_experience": "mid",
|
| 865 |
+
"language": "go",
|
| 866 |
+
"filename": "pkg/retry/backoff.go",
|
| 867 |
+
"diff": '''@@ -8,4 +8,28 @@ type RetryConfig struct {
|
| 868 |
+
+func WithRetry(config RetryConfig, fn func() error) error {
|
| 869 |
+
+ var lastErr error
|
| 870 |
+
+
|
| 871 |
+
+ for attempt := 0; attempt < config.MaxRetries; attempt++ {
|
| 872 |
+
+ err := fn()
|
| 873 |
+
+ if err == nil {
|
| 874 |
+
+ return nil
|
| 875 |
+
+ }
|
| 876 |
+
+ lastErr = err
|
| 877 |
+
+
|
| 878 |
+
+ // BUG: Delay doesn't actually use exponential backoff
|
| 879 |
+
+ // Should be: baseDelay * 2^attempt
|
| 880 |
+
+ delay := config.BaseDelay * time.Duration(attempt)
|
| 881 |
+
+ // When attempt=0, delay is 0 — no backoff on first retry
|
| 882 |
+
+
|
| 883 |
+
+ // BUG: No jitter — all clients retry at exact same time (thundering herd)
|
| 884 |
+
+ time.Sleep(delay)
|
| 885 |
+
+
|
| 886 |
+
+ // BUG: No context cancellation check — retries continue even if canceled
|
| 887 |
+
+ }
|
| 888 |
+
+
|
| 889 |
+
+ // BUG: Returns nil instead of lastErr when all retries exhausted
|
| 890 |
+
+ // due to loop boundary — attempt reaches MaxRetries and exits
|
| 891 |
+
+ return nil // Should return lastErr
|
| 892 |
+
+}''',
|
| 893 |
+
"lines_changed": 22,
|
| 894 |
+
"has_tests": True,
|
| 895 |
+
"bug_category": "logic_error",
|
| 896 |
+
"ground_truth_severity": "medium",
|
| 897 |
+
"bug_lines": [19, 22, 24, 29],
|
| 898 |
+
"human_labels": ["medium", "medium", "high"],
|
| 899 |
+
"human_agreement": 0.67,
|
| 900 |
+
"cohen_kappa": 0.72,
|
| 901 |
+
},
|
| 902 |
+
# ── 20. Java style only ─────────────────────────────────────────────
|
| 903 |
+
{
|
| 904 |
+
"pr_id": "PR-020",
|
| 905 |
+
"title": "Refactor StringUtils for readability",
|
| 906 |
+
"description": "Cleaned up StringUtils class. Renamed methods to follow Java conventions, added Javadoc.",
|
| 907 |
+
"author_experience": "senior",
|
| 908 |
+
"language": "java",
|
| 909 |
+
"filename": "src/main/java/com/app/StringUtils.java",
|
| 910 |
+
"diff": '''@@ -1,20 +1,20 @@
|
| 911 |
+
-public class StringUtils {
|
| 912 |
+
- public static String CAMELCASE(String input) {
|
| 913 |
+
- String[] parts = input.split("_");
|
| 914 |
+
- StringBuilder sb = new StringBuilder();
|
| 915 |
+
- for (String p : parts) {
|
| 916 |
+
- sb.append(p.substring(0, 1).toUpperCase());
|
| 917 |
+
- sb.append(p.substring(1).toLowerCase());
|
| 918 |
+
- }
|
| 919 |
+
- return sb.toString();
|
| 920 |
+
- }
|
| 921 |
+
- public static boolean checkEmpty(String s){
|
| 922 |
+
- if(s == null) return true;
|
| 923 |
+
- if(s.trim().length() == 0) return true;
|
| 924 |
+
- return false;
|
| 925 |
+
- }
|
| 926 |
+
-}
|
| 927 |
+
+/**
|
| 928 |
+
+ * Utility class for common string operations.
|
| 929 |
+
+ */
|
| 930 |
+
+public class StringUtils {
|
| 931 |
+
+ /**
|
| 932 |
+
+ * Convert snake_case to CamelCase.
|
| 933 |
+
+ */
|
| 934 |
+
+ public static String toCamelCase(String input) {
|
| 935 |
+
+ String[] parts = input.split("_");
|
| 936 |
+
+ StringBuilder sb = new StringBuilder();
|
| 937 |
+
+ for (String part : parts) {
|
| 938 |
+
+ sb.append(part.substring(0, 1).toUpperCase());
|
| 939 |
+
+ sb.append(part.substring(1).toLowerCase());
|
| 940 |
+
+ }
|
| 941 |
+
+ return sb.toString();
|
| 942 |
+
+ }
|
| 943 |
+
+
|
| 944 |
+
+ /**
|
| 945 |
+
+ * Check if a string is null or blank.
|
| 946 |
+
+ */
|
| 947 |
+
+ public static boolean isBlank(String value) {
|
| 948 |
+
+ return value == null || value.trim().isEmpty();
|
| 949 |
+
+ }
|
| 950 |
+
+}''',
|
| 951 |
+
"lines_changed": 24,
|
| 952 |
+
"has_tests": True,
|
| 953 |
+
"bug_category": "style_only",
|
| 954 |
+
"ground_truth_severity": "none",
|
| 955 |
+
"bug_lines": [],
|
| 956 |
+
"human_labels": ["none", "none", "none"],
|
| 957 |
+
"human_agreement": 1.0,
|
| 958 |
+
"cohen_kappa": 1.0,
|
| 959 |
+
},
|
| 960 |
+
]
|
| 961 |
+
|
| 962 |
+
|
| 963 |
+
def _build_pr_file(template: Dict) -> PRFile:
|
| 964 |
+
"""Convert a template dict to a PRFile model."""
|
| 965 |
+
return PRFile(
|
| 966 |
+
filename=template["filename"],
|
| 967 |
+
language=template["language"],
|
| 968 |
+
diff=template["diff"],
|
| 969 |
+
lines_changed=template["lines_changed"],
|
| 970 |
+
has_tests=template["has_tests"],
|
| 971 |
+
)
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
def _build_observation(
|
| 975 |
+
template: Dict,
|
| 976 |
+
step_number: int,
|
| 977 |
+
episode_budget: int,
|
| 978 |
+
review_queue: List[str],
|
| 979 |
+
existing_comments: Optional[List[str]] = None,
|
| 980 |
+
) -> Observation:
|
| 981 |
+
"""Convert a template dict to a full Observation."""
|
| 982 |
+
return Observation(
|
| 983 |
+
pr_id=template["pr_id"],
|
| 984 |
+
title=template["title"],
|
| 985 |
+
description=template["description"],
|
| 986 |
+
author_experience=template["author_experience"],
|
| 987 |
+
files=[_build_pr_file(template)],
|
| 988 |
+
existing_comments=existing_comments or [],
|
| 989 |
+
review_queue=review_queue,
|
| 990 |
+
step_number=step_number,
|
| 991 |
+
episode_budget=episode_budget,
|
| 992 |
+
)
|
| 993 |
+
|
| 994 |
+
|
| 995 |
+
def get_ground_truth(pr_id: str) -> Dict:
|
| 996 |
+
"""
|
| 997 |
+
Get ground truth for a PR by its ID.
|
| 998 |
+
|
| 999 |
+
Returns dict with: bug_category, ground_truth_severity, bug_lines,
|
| 1000 |
+
human_labels, human_agreement, cohen_kappa.
|
| 1001 |
+
|
| 1002 |
+
Used by graders for deterministic scoring.
|
| 1003 |
+
"""
|
| 1004 |
+
for t in PR_TEMPLATES:
|
| 1005 |
+
if t["pr_id"] == pr_id:
|
| 1006 |
+
return {
|
| 1007 |
+
"bug_category": t["bug_category"],
|
| 1008 |
+
"ground_truth_severity": t["ground_truth_severity"],
|
| 1009 |
+
"bug_lines": t["bug_lines"],
|
| 1010 |
+
"human_labels": t["human_labels"],
|
| 1011 |
+
"human_agreement": t["human_agreement"],
|
| 1012 |
+
"cohen_kappa": t["cohen_kappa"],
|
| 1013 |
+
}
|
| 1014 |
+
raise ValueError(f"Unknown PR ID: {pr_id}")
|
| 1015 |
+
|
| 1016 |
+
|
| 1017 |
+
def get_template_by_id(pr_id: str) -> Dict:
|
| 1018 |
+
"""Get full template dict by PR ID."""
|
| 1019 |
+
for t in PR_TEMPLATES:
|
| 1020 |
+
if t["pr_id"] == pr_id:
|
| 1021 |
+
return t
|
| 1022 |
+
raise ValueError(f"Unknown PR ID: {pr_id}")
|
| 1023 |
+
|
| 1024 |
+
|
| 1025 |
+
class DataGenerator:
|
| 1026 |
+
"""
|
| 1027 |
+
Generates episodes of PRs for each task difficulty.
|
| 1028 |
+
|
| 1029 |
+
Uses FIXED_TEST_SUITE: all 20 pre-generated PR templates.
|
| 1030 |
+
Randomness affects only ordering within episodes, not PR content.
|
| 1031 |
+
This ensures evaluation is deterministic given the same seed.
|
| 1032 |
+
"""
|
| 1033 |
+
|
| 1034 |
+
def __init__(self, seed: int = 42):
|
| 1035 |
+
self.seed = seed
|
| 1036 |
+
self.rng = random.Random(seed)
|
| 1037 |
+
self.all_templates = list(PR_TEMPLATES)
|
| 1038 |
+
|
| 1039 |
+
def generate_easy_episode(self, episode_length: int = 5) -> List[Dict]:
|
| 1040 |
+
"""
|
| 1041 |
+
Generate an episode for easy task: sequence of individual PRs.
|
| 1042 |
+
|
| 1043 |
+
Returns list of templates (one per step). The agent must label
|
| 1044 |
+
each PR's severity.
|
| 1045 |
+
"""
|
| 1046 |
+
# Shuffle deterministically, pick episode_length PRs
|
| 1047 |
+
pool = list(self.all_templates)
|
| 1048 |
+
self.rng.shuffle(pool)
|
| 1049 |
+
return pool[:episode_length]
|
| 1050 |
+
|
| 1051 |
+
def generate_medium_episode(self, num_queues: int = 3, queue_size: int = 5) -> List[List[Dict]]:
|
| 1052 |
+
"""
|
| 1053 |
+
Generate an episode for medium task: sequence of PR queues.
|
| 1054 |
+
|
| 1055 |
+
Returns list of queues, each queue is a list of templates.
|
| 1056 |
+
The agent must order each queue by priority.
|
| 1057 |
+
"""
|
| 1058 |
+
pool = list(self.all_templates)
|
| 1059 |
+
self.rng.shuffle(pool)
|
| 1060 |
+
|
| 1061 |
+
queues = []
|
| 1062 |
+
for i in range(num_queues):
|
| 1063 |
+
start = (i * queue_size) % len(pool)
|
| 1064 |
+
queue = []
|
| 1065 |
+
for j in range(queue_size):
|
| 1066 |
+
idx = (start + j) % len(pool)
|
| 1067 |
+
queue.append(pool[idx])
|
| 1068 |
+
# Shuffle within queue so agent can't rely on ordering
|
| 1069 |
+
self.rng.shuffle(queue)
|
| 1070 |
+
queues.append(queue)
|
| 1071 |
+
|
| 1072 |
+
return queues
|
| 1073 |
+
|
| 1074 |
+
def generate_hard_episode(self, num_prs: int = 3) -> List[Dict]:
|
| 1075 |
+
"""
|
| 1076 |
+
Generate an episode for hard task: PRs requiring detailed review.
|
| 1077 |
+
|
| 1078 |
+
Returns list of templates. For each PR, the agent may make
|
| 1079 |
+
multiple add_comment actions before approve/request_changes.
|
| 1080 |
+
Prioritize PRs with bugs for more interesting review scenarios.
|
| 1081 |
+
"""
|
| 1082 |
+
# Select PRs with a mix of severities — ensure at least one critical
|
| 1083 |
+
critical = [t for t in self.all_templates if t["ground_truth_severity"] == "critical"]
|
| 1084 |
+
non_critical = [t for t in self.all_templates if t["ground_truth_severity"] != "critical"]
|
| 1085 |
+
|
| 1086 |
+
self.rng.shuffle(critical)
|
| 1087 |
+
self.rng.shuffle(non_critical)
|
| 1088 |
+
|
| 1089 |
+
selected = []
|
| 1090 |
+
if critical:
|
| 1091 |
+
selected.append(critical[0])
|
| 1092 |
+
remaining_needed = num_prs - len(selected)
|
| 1093 |
+
selected.extend(non_critical[:remaining_needed])
|
| 1094 |
+
|
| 1095 |
+
self.rng.shuffle(selected)
|
| 1096 |
+
return selected[:num_prs]
|
| 1097 |
+
|
| 1098 |
+
def compute_priority_order(self, queue: List[Dict]) -> List[str]:
|
| 1099 |
+
"""
|
| 1100 |
+
Compute ground truth priority ordering for a queue of PRs.
|
| 1101 |
+
|
| 1102 |
+
Priority rules (in order):
|
| 1103 |
+
1. Security PRs always first
|
| 1104 |
+
2. By severity: critical > high > medium > low > none
|
| 1105 |
+
3. Within same severity: junior authors first (they need review most urgently)
|
| 1106 |
+
"""
|
| 1107 |
+
severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3, "none": 4}
|
| 1108 |
+
experience_rank = {"junior": 0, "mid": 1, "senior": 2}
|
| 1109 |
+
|
| 1110 |
+
def sort_key(template):
|
| 1111 |
+
is_security = 1 if template["bug_category"] in ("security_vulnerability", "sql_injection") else 0
|
| 1112 |
+
sev = severity_rank.get(template["ground_truth_severity"], 4)
|
| 1113 |
+
exp = experience_rank.get(template["author_experience"], 2)
|
| 1114 |
+
# Lower = higher priority. Security first, then severity, then experience
|
| 1115 |
+
return (1 - is_security, sev, exp)
|
| 1116 |
+
|
| 1117 |
+
sorted_queue = sorted(queue, key=sort_key)
|
| 1118 |
+
return [t["pr_id"] for t in sorted_queue]
|
env/models.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic Models for CodeReviewEnv
|
| 3 |
+
|
| 4 |
+
Defines the complete type system for the Semantic MDP:
|
| 5 |
+
- PRFile: individual file in a pull request
|
| 6 |
+
- Observation: the full state visible to the agent (s ∈ S)
|
| 7 |
+
- Action: the structured decision space (a ∈ A)
|
| 8 |
+
- Reward: shaped reward with component breakdown (R: S×A×S' → [-1,1])
|
| 9 |
+
- State: full environment state including trajectory history
|
| 10 |
+
|
| 11 |
+
All models are serializable to JSON for trajectory logging and API transport.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from pydantic import BaseModel, field_validator
|
| 15 |
+
from typing import List, Optional, Dict, Any
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PRFile(BaseModel):
|
| 19 |
+
"""A single file within a pull request diff."""
|
| 20 |
+
filename: str
|
| 21 |
+
language: str # python | javascript | java | go
|
| 22 |
+
diff: str
|
| 23 |
+
lines_changed: int
|
| 24 |
+
has_tests: bool
|
| 25 |
+
|
| 26 |
+
@field_validator("language")
|
| 27 |
+
@classmethod
|
| 28 |
+
def validate_language(cls, v: str) -> str:
|
| 29 |
+
allowed = {"python", "javascript", "java", "go"}
|
| 30 |
+
if v not in allowed:
|
| 31 |
+
raise ValueError(f"language must be one of {allowed}")
|
| 32 |
+
return v
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class Observation(BaseModel):
|
| 36 |
+
"""
|
| 37 |
+
The agent's observation at each step — the semantic state s ∈ S.
|
| 38 |
+
|
| 39 |
+
Unlike continuous MBRL state spaces (e.g. MuJoCo joint angles),
|
| 40 |
+
this is structured text carrying semantic meaning: code diffs,
|
| 41 |
+
author context, review history. A world model must learn to
|
| 42 |
+
predict how review actions transform this state.
|
| 43 |
+
"""
|
| 44 |
+
pr_id: str
|
| 45 |
+
title: str
|
| 46 |
+
description: str
|
| 47 |
+
author_experience: str # junior | mid | senior
|
| 48 |
+
files: List[PRFile]
|
| 49 |
+
existing_comments: List[str]
|
| 50 |
+
review_queue: List[str]
|
| 51 |
+
step_number: int
|
| 52 |
+
episode_budget: int
|
| 53 |
+
|
| 54 |
+
@field_validator("author_experience")
|
| 55 |
+
@classmethod
|
| 56 |
+
def validate_experience(cls, v: str) -> str:
|
| 57 |
+
allowed = {"junior", "mid", "senior"}
|
| 58 |
+
if v not in allowed:
|
| 59 |
+
raise ValueError(f"author_experience must be one of {allowed}")
|
| 60 |
+
return v
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class Action(BaseModel):
|
| 64 |
+
"""
|
| 65 |
+
The agent's action — a structured decision a ∈ A.
|
| 66 |
+
|
| 67 |
+
The action space is heterogeneous: different action_types require
|
| 68 |
+
different fields. This is fundamentally different from continuous
|
| 69 |
+
action spaces in standard MBRL — it requires structured encoding
|
| 70 |
+
for world model training.
|
| 71 |
+
"""
|
| 72 |
+
action_type: str # label_severity | prioritize | add_comment | approve | request_changes
|
| 73 |
+
severity: Optional[str] = None # critical | high | medium | low | none
|
| 74 |
+
priority_order: Optional[List[str]] = None
|
| 75 |
+
comment: Optional[str] = None
|
| 76 |
+
target_file: Optional[str] = None
|
| 77 |
+
target_line: Optional[int] = None
|
| 78 |
+
|
| 79 |
+
@field_validator("action_type")
|
| 80 |
+
@classmethod
|
| 81 |
+
def validate_action_type(cls, v: str) -> str:
|
| 82 |
+
allowed = {"label_severity", "prioritize", "add_comment", "approve", "request_changes"}
|
| 83 |
+
if v not in allowed:
|
| 84 |
+
raise ValueError(f"action_type must be one of {allowed}")
|
| 85 |
+
return v
|
| 86 |
+
|
| 87 |
+
@field_validator("severity")
|
| 88 |
+
@classmethod
|
| 89 |
+
def validate_severity(cls, v: Optional[str]) -> Optional[str]:
|
| 90 |
+
if v is not None:
|
| 91 |
+
allowed = {"critical", "high", "medium", "low", "none"}
|
| 92 |
+
if v not in allowed:
|
| 93 |
+
raise ValueError(f"severity must be one of {allowed}")
|
| 94 |
+
return v
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class Reward(BaseModel):
|
| 98 |
+
"""
|
| 99 |
+
Shaped reward R: S × A × S' → [-1, 1].
|
| 100 |
+
|
| 101 |
+
The breakdown dict exposes every component for analysis:
|
| 102 |
+
step_reward, efficiency_bonus, coverage_bonus, consistency_penalty.
|
| 103 |
+
This transparency is critical for reward attribution research.
|
| 104 |
+
"""
|
| 105 |
+
value: float
|
| 106 |
+
breakdown: Dict[str, float]
|
| 107 |
+
reason: str
|
| 108 |
+
|
| 109 |
+
@field_validator("value")
|
| 110 |
+
@classmethod
|
| 111 |
+
def clamp_reward(cls, v: float) -> float:
|
| 112 |
+
return max(-1.0, min(1.0, v))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class State(BaseModel):
|
| 116 |
+
"""
|
| 117 |
+
Full environment state including trajectory history.
|
| 118 |
+
|
| 119 |
+
The trajectory list enables in-episode analysis and is the raw
|
| 120 |
+
material for semantic world model training datasets.
|
| 121 |
+
"""
|
| 122 |
+
current_pr: Observation
|
| 123 |
+
reviewed_prs: List[str]
|
| 124 |
+
pending_prs: List[str]
|
| 125 |
+
total_reward: float
|
| 126 |
+
step: int
|
| 127 |
+
done: bool
|
| 128 |
+
trajectory: List[Dict[str, Any]]
|
env/trajectory_logger.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Trajectory Logger — MBRL Dataset Hook
|
| 3 |
+
|
| 4 |
+
Each trajectory file is a valid training dataset for a semantic world model.
|
| 5 |
+
Format: JSONL where each line is one (s, a, r, s') transition.
|
| 6 |
+
|
| 7 |
+
To train a world model:
|
| 8 |
+
1. Load trajectories from trajectories/ directory
|
| 9 |
+
2. Encode states with an LLM encoder (e.g. sentence-transformers)
|
| 10 |
+
3. Train transition model f(s_t, a_t) -> (s_{t+1}, r_t)
|
| 11 |
+
4. Use model for planning without real env — Dyna-Q over language state space
|
| 12 |
+
|
| 13 |
+
This is the first step toward model-based planning over knowledge-work
|
| 14 |
+
environments. No existing MBRL benchmark provides this for semantic state spaces.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
from datetime import datetime, timezone
|
| 20 |
+
from typing import Dict, List, Optional
|
| 21 |
+
|
| 22 |
+
from env.models import Observation, Action, Reward
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TrajectoryLogger:
|
| 26 |
+
"""
|
| 27 |
+
Logs (state, action, reward, next_state) transitions in JSONL format.
|
| 28 |
+
|
| 29 |
+
Each episode produces one JSONL file, each line is one transition.
|
| 30 |
+
This format is directly consumable by dataset loaders for semantic
|
| 31 |
+
world model training — see world_model/scaffold.py.
|
| 32 |
+
|
| 33 |
+
Research motivation:
|
| 34 |
+
Standard MBRL benchmarks (MuJoCo, Atari) log transitions as
|
| 35 |
+
numerical vectors. SemanticTransitionDataset wraps these JSONL
|
| 36 |
+
files and provides encoding hooks for structured text states.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
def __init__(self, output_dir: str = "trajectories"):
|
| 40 |
+
self.output_dir = output_dir
|
| 41 |
+
self.transitions: List[Dict] = []
|
| 42 |
+
self.episode_id: Optional[str] = None
|
| 43 |
+
self.task: Optional[str] = None
|
| 44 |
+
os.makedirs(self.output_dir, exist_ok=True)
|
| 45 |
+
|
| 46 |
+
def start_episode(self, episode_id: str, task: str) -> None:
|
| 47 |
+
"""Begin a new episode, clearing any existing transition buffer."""
|
| 48 |
+
self.episode_id = episode_id
|
| 49 |
+
self.task = task
|
| 50 |
+
self.transitions = []
|
| 51 |
+
|
| 52 |
+
def log_transition(
|
| 53 |
+
self,
|
| 54 |
+
step: int,
|
| 55 |
+
state: Observation,
|
| 56 |
+
action: Action,
|
| 57 |
+
reward: Reward,
|
| 58 |
+
next_state: Observation,
|
| 59 |
+
done: bool,
|
| 60 |
+
) -> None:
|
| 61 |
+
"""
|
| 62 |
+
Log a single (s, a, r, s') transition.
|
| 63 |
+
|
| 64 |
+
Each transition is a complete snapshot suitable for world model
|
| 65 |
+
training: given (state, action), predict (next_state, reward).
|
| 66 |
+
"""
|
| 67 |
+
transition = {
|
| 68 |
+
"step": step,
|
| 69 |
+
"state": state.model_dump(),
|
| 70 |
+
"action": action.model_dump(),
|
| 71 |
+
"reward": reward.model_dump(),
|
| 72 |
+
"next_state": next_state.model_dump(),
|
| 73 |
+
"done": done,
|
| 74 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 75 |
+
"episode_id": self.episode_id,
|
| 76 |
+
"task": self.task,
|
| 77 |
+
}
|
| 78 |
+
self.transitions.append(transition)
|
| 79 |
+
|
| 80 |
+
def save(self) -> str:
|
| 81 |
+
"""
|
| 82 |
+
Save episode trajectory to JSONL file.
|
| 83 |
+
|
| 84 |
+
Returns the filepath of the saved trajectory.
|
| 85 |
+
Format: trajectories/{task}_{episode_id}.jsonl
|
| 86 |
+
"""
|
| 87 |
+
if not self.transitions:
|
| 88 |
+
return ""
|
| 89 |
+
|
| 90 |
+
filename = f"{self.task}_{self.episode_id}.jsonl"
|
| 91 |
+
filepath = os.path.join(self.output_dir, filename)
|
| 92 |
+
|
| 93 |
+
with open(filepath, "w") as f:
|
| 94 |
+
for transition in self.transitions:
|
| 95 |
+
f.write(json.dumps(transition, default=str) + "\n")
|
| 96 |
+
|
| 97 |
+
return filepath
|
| 98 |
+
|
| 99 |
+
def export(self) -> List[Dict]:
|
| 100 |
+
"""
|
| 101 |
+
Return full episode as list of dicts.
|
| 102 |
+
|
| 103 |
+
Clean JSONL-ready format for world model training dataset.
|
| 104 |
+
Each dict has keys: step, state, action, reward, next_state, done, timestamp.
|
| 105 |
+
"""
|
| 106 |
+
return list(self.transitions)
|
| 107 |
+
|
| 108 |
+
def reset(self) -> None:
|
| 109 |
+
"""Clear transition buffer for new episode."""
|
| 110 |
+
self.transitions = []
|
| 111 |
+
self.episode_id = None
|
| 112 |
+
self.task = None
|
eval_live.py
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
CodeReviewEnv — Real-Time Live Evaluation (WebSocket)
|
| 4 |
+
======================================================
|
| 5 |
+
Drives the LIVE HF Space endpoint using WebSocket sessions for stateful
|
| 6 |
+
interaction. Uses real OpenRouter / OpenAI-compatible LLM calls.
|
| 7 |
+
|
| 8 |
+
The WebSocket endpoint (/ws) creates a persistent environment session,
|
| 9 |
+
preserving state across reset() and step() calls within a single
|
| 10 |
+
connection. This is critical — the HTTP endpoints (/reset, /step) are
|
| 11 |
+
stateless and create fresh environments on every call.
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
OPENAI_API_KEY=sk-or-... python3 eval_live.py [--model MODEL] [--task all|easy|medium|hard]
|
| 15 |
+
|
| 16 |
+
Environment variables:
|
| 17 |
+
OPENAI_API_KEY OpenRouter or OpenAI key (required)
|
| 18 |
+
API_BASE_URL LLM gateway base URL (default: https://openrouter.ai/api/v1)
|
| 19 |
+
MODEL_NAME Model to use (default: openai/gpt-4o-mini)
|
| 20 |
+
ENV_BASE_URL Live Space URL (default: https://ragavrida-code-review-env.hf.space)
|
| 21 |
+
SEED Episode seed (default: 42)
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import asyncio
|
| 25 |
+
import os
|
| 26 |
+
import re
|
| 27 |
+
import sys
|
| 28 |
+
import json
|
| 29 |
+
import time
|
| 30 |
+
import argparse
|
| 31 |
+
import statistics
|
| 32 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 33 |
+
|
| 34 |
+
import requests
|
| 35 |
+
from openai import OpenAI
|
| 36 |
+
import websockets
|
| 37 |
+
|
| 38 |
+
# ─── Configuration ────────────────────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
ENV_BASE_URL = os.getenv("ENV_BASE_URL", "https://ragavrida-code-review-env.hf.space")
|
| 41 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://openrouter.ai/api/v1")
|
| 42 |
+
API_KEY = os.getenv("OPENAI_API_KEY") or os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
| 43 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "openai/gpt-4o-mini")
|
| 44 |
+
SEED = int(os.getenv("SEED", "42"))
|
| 45 |
+
TEMPERATURE = 0.0
|
| 46 |
+
MAX_TOKENS = 512
|
| 47 |
+
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1")
|
| 48 |
+
|
| 49 |
+
TASKS = ["easy", "medium", "hard"]
|
| 50 |
+
|
| 51 |
+
# ─── WebSocket helpers ────────────────────────────────────────────────────────
|
| 52 |
+
|
| 53 |
+
def _ws_url() -> str:
|
| 54 |
+
"""Convert HTTP URL to WebSocket URL."""
|
| 55 |
+
base = ENV_BASE_URL.rstrip("/")
|
| 56 |
+
if base.startswith("https://"):
|
| 57 |
+
return base.replace("https://", "wss://") + "/ws"
|
| 58 |
+
elif base.startswith("http://"):
|
| 59 |
+
return base.replace("http://", "ws://") + "/ws"
|
| 60 |
+
return base + "/ws"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
async def ws_episode(task: str, seed: int, step_fn) -> List[Dict]:
|
| 64 |
+
"""
|
| 65 |
+
Run a full episode over a single WebSocket session.
|
| 66 |
+
|
| 67 |
+
Opens a WS connection, sends reset, then repeatedly calls step_fn
|
| 68 |
+
to get the next action and sends it. Returns the list of step results.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
task: "easy" | "medium" | "hard"
|
| 72 |
+
seed: episode seed
|
| 73 |
+
step_fn: callable(obs_data: Dict, step: int) -> Dict (action dict)
|
| 74 |
+
Returns None to stop the episode.
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
List of (obs, reward, done, action) dicts for each step.
|
| 78 |
+
"""
|
| 79 |
+
ws_url = _ws_url()
|
| 80 |
+
results = []
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
async with websockets.connect(
|
| 84 |
+
ws_url,
|
| 85 |
+
ping_interval=30,
|
| 86 |
+
ping_timeout=60,
|
| 87 |
+
close_timeout=10,
|
| 88 |
+
max_size=10 * 1024 * 1024, # 10MB max message
|
| 89 |
+
) as ws:
|
| 90 |
+
# ── Reset ────────────────────────────────────────────────
|
| 91 |
+
reset_msg = json.dumps({
|
| 92 |
+
"type": "reset",
|
| 93 |
+
"data": {"task": task, "seed": seed},
|
| 94 |
+
})
|
| 95 |
+
await ws.send(reset_msg)
|
| 96 |
+
reset_resp = json.loads(await ws.recv())
|
| 97 |
+
|
| 98 |
+
if reset_resp.get("type") == "error":
|
| 99 |
+
print(f" [ERROR] Reset failed: {reset_resp.get('data', {}).get('message', 'unknown')}")
|
| 100 |
+
return results
|
| 101 |
+
|
| 102 |
+
# Parse reset response
|
| 103 |
+
resp_data = reset_resp.get("data", {})
|
| 104 |
+
obs = resp_data.get("observation", resp_data)
|
| 105 |
+
reward = resp_data.get("reward") or 0.0
|
| 106 |
+
done = resp_data.get("done", False)
|
| 107 |
+
|
| 108 |
+
# ── Step loop ────────────────────────────────────────────
|
| 109 |
+
step = 0
|
| 110 |
+
max_steps = {"easy": 5, "medium": 3, "hard": 21}.get(task, 10)
|
| 111 |
+
|
| 112 |
+
while not done and step < max_steps:
|
| 113 |
+
action = step_fn(obs, step)
|
| 114 |
+
if action is None:
|
| 115 |
+
break
|
| 116 |
+
|
| 117 |
+
step_msg = json.dumps({
|
| 118 |
+
"type": "step",
|
| 119 |
+
"data": action,
|
| 120 |
+
})
|
| 121 |
+
await ws.send(step_msg)
|
| 122 |
+
step_resp = json.loads(await ws.recv())
|
| 123 |
+
|
| 124 |
+
if step_resp.get("type") == "error":
|
| 125 |
+
print(f" [ERROR] Step {step} failed: {step_resp.get('data', {}).get('message', 'unknown')}")
|
| 126 |
+
break
|
| 127 |
+
|
| 128 |
+
resp_data = step_resp.get("data", {})
|
| 129 |
+
obs = resp_data.get("observation", resp_data)
|
| 130 |
+
reward = resp_data.get("reward") or 0.0
|
| 131 |
+
done = resp_data.get("done", False)
|
| 132 |
+
|
| 133 |
+
results.append({
|
| 134 |
+
"step": step,
|
| 135 |
+
"obs": obs,
|
| 136 |
+
"reward": reward,
|
| 137 |
+
"done": done,
|
| 138 |
+
"action": action,
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
step += 1
|
| 142 |
+
|
| 143 |
+
# ── Close ────────────────────────────────────────────────
|
| 144 |
+
try:
|
| 145 |
+
close_msg = json.dumps({"type": "close"})
|
| 146 |
+
await ws.send(close_msg)
|
| 147 |
+
except Exception:
|
| 148 |
+
pass
|
| 149 |
+
|
| 150 |
+
except Exception as exc:
|
| 151 |
+
print(f" [ERROR] WebSocket error: {exc}")
|
| 152 |
+
|
| 153 |
+
return results
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ─── HTTP health check (still use HTTP for simple health) ─────────────────────
|
| 157 |
+
|
| 158 |
+
def env_health() -> bool:
|
| 159 |
+
"""Ping /health and return True if up."""
|
| 160 |
+
try:
|
| 161 |
+
r = requests.get(f"{ENV_BASE_URL.rstrip('/')}/health", timeout=10)
|
| 162 |
+
return r.status_code == 200 and r.json().get("status") == "healthy"
|
| 163 |
+
except Exception as exc:
|
| 164 |
+
print(f" [ERROR] Health check failed: {exc}")
|
| 165 |
+
return False
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ─── LLM helpers ─────────────────────────────────────────────────────────────
|
| 169 |
+
|
| 170 |
+
def call_llm(client: OpenAI, system: str, user: str) -> str:
|
| 171 |
+
"""Call the configured model and return raw text (empty string on error)."""
|
| 172 |
+
try:
|
| 173 |
+
resp = client.chat.completions.create(
|
| 174 |
+
model=MODEL_NAME,
|
| 175 |
+
messages=[
|
| 176 |
+
{"role": "system", "content": system},
|
| 177 |
+
{"role": "user", "content": user},
|
| 178 |
+
],
|
| 179 |
+
temperature=TEMPERATURE,
|
| 180 |
+
max_tokens=MAX_TOKENS,
|
| 181 |
+
)
|
| 182 |
+
return resp.choices[0].message.content or ""
|
| 183 |
+
except Exception as exc:
|
| 184 |
+
print(f" [LLM ERROR] {exc}")
|
| 185 |
+
return ""
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def parse_json(text: str) -> Optional[Dict]:
|
| 189 |
+
"""Robustly extract JSON from a response that may contain markdown."""
|
| 190 |
+
text = text.strip()
|
| 191 |
+
# Strip ``` fences
|
| 192 |
+
if text.startswith("```"):
|
| 193 |
+
lines = [l for l in text.splitlines() if not l.strip().startswith("```")]
|
| 194 |
+
text = "\n".join(lines).strip()
|
| 195 |
+
# Try direct parse
|
| 196 |
+
try:
|
| 197 |
+
return json.loads(text)
|
| 198 |
+
except json.JSONDecodeError:
|
| 199 |
+
pass
|
| 200 |
+
# Find outermost {...}
|
| 201 |
+
for pattern in [r'\{.*\}', r'\{[^{}]*\}']:
|
| 202 |
+
m = re.search(pattern, text, re.DOTALL)
|
| 203 |
+
if m:
|
| 204 |
+
try:
|
| 205 |
+
return json.loads(m.group())
|
| 206 |
+
except json.JSONDecodeError:
|
| 207 |
+
pass
|
| 208 |
+
return None
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ─── System prompts ───────────────────────────────────────────────────────────
|
| 212 |
+
|
| 213 |
+
PROMPTS = {
|
| 214 |
+
"easy": """\
|
| 215 |
+
You are a senior software engineer performing code review.
|
| 216 |
+
Assess the severity of bugs in the pull request shown.
|
| 217 |
+
|
| 218 |
+
Severity scale:
|
| 219 |
+
- "critical": Security vulnerabilities (SQL injection, auth bypass, hardcoded secrets)
|
| 220 |
+
- "high": Crashes or data corruption (null pointer, race condition)
|
| 221 |
+
- "medium": Logic errors or missing error handling
|
| 222 |
+
- "low": Performance issues (N+1 queries, unnecessary loops)
|
| 223 |
+
- "none": Style-only, no bugs
|
| 224 |
+
|
| 225 |
+
Respond ONLY with valid JSON — no prose, no markdown.
|
| 226 |
+
Format: {"action_type": "label_severity", "severity": "<critical|high|medium|low|none>"}""",
|
| 227 |
+
|
| 228 |
+
"medium": """\
|
| 229 |
+
You are a senior software engineer managing a code review queue.
|
| 230 |
+
Order the given PRs by review priority (most urgent first).
|
| 231 |
+
|
| 232 |
+
Priority rules:
|
| 233 |
+
1. Security PRs (SQL injection, auth) top priority
|
| 234 |
+
2. Higher severity bugs before lower severity
|
| 235 |
+
3. Junior-author PRs need earlier review
|
| 236 |
+
4. PRs without tests should be reviewed sooner
|
| 237 |
+
|
| 238 |
+
Respond ONLY with valid JSON — no prose, no markdown.
|
| 239 |
+
Format: {"action_type": "prioritize", "priority_order": ["PR-XXX", "PR-YYY", ...]}""",
|
| 240 |
+
|
| 241 |
+
"hard": """\
|
| 242 |
+
You are a senior software engineer performing detailed code review.
|
| 243 |
+
You must:
|
| 244 |
+
1. Add specific, actionable comments targeting buggy lines
|
| 245 |
+
2. Then approve or request changes
|
| 246 |
+
|
| 247 |
+
For each comment:
|
| 248 |
+
{"action_type": "add_comment", "comment": "<specific fix suggestion>",
|
| 249 |
+
"target_file": "<filename>", "target_line": <line_number>}
|
| 250 |
+
|
| 251 |
+
When done:
|
| 252 |
+
{"action_type": "request_changes"} — if there are bugs
|
| 253 |
+
{"action_type": "approve"} — if clean
|
| 254 |
+
|
| 255 |
+
Use domain-specific keywords (null check, parameterized query, mutex lock etc.).
|
| 256 |
+
Respond ONLY with valid JSON — no prose, no markdown.""",
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ─── Observation formatters ───────────────────────────────────────────────────
|
| 261 |
+
|
| 262 |
+
def fmt_easy(obs: Dict) -> str:
|
| 263 |
+
files_text = ""
|
| 264 |
+
for f in obs.get("files", []):
|
| 265 |
+
files_text += (
|
| 266 |
+
f"\n--- {f.get('filename','?')} "
|
| 267 |
+
f"({f.get('language','?')}, {f.get('lines_changed','?')} lines, "
|
| 268 |
+
f"{'has tests' if f.get('has_tests') else 'no tests'}) ---\n"
|
| 269 |
+
f"{f.get('diff','')}\n"
|
| 270 |
+
)
|
| 271 |
+
return (
|
| 272 |
+
f"PR: {obs.get('pr_id','?')}\n"
|
| 273 |
+
f"Title: {obs.get('title','?')}\n"
|
| 274 |
+
f"Description: {obs.get('description','?')}\n"
|
| 275 |
+
f"Author: {obs.get('author_experience','?')}\n"
|
| 276 |
+
f"{files_text}\n"
|
| 277 |
+
f"Step {obs.get('step_number',0)+1} — What is the bug severity?"
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def fmt_medium(obs: Dict) -> str:
|
| 282 |
+
queue = obs.get("review_queue", [])
|
| 283 |
+
pr_lines = ""
|
| 284 |
+
for pr_id in queue:
|
| 285 |
+
pr_lines += f"\n - {pr_id}"
|
| 286 |
+
return (
|
| 287 |
+
f"Current PR: {obs.get('pr_id','?')} | Queue: {queue}\n"
|
| 288 |
+
f"Title: {obs.get('title','?')} | Author: {obs.get('author_experience','?')}\n"
|
| 289 |
+
f"\nPRs to prioritize (most urgent first):{pr_lines or ' (none listed)'}\n"
|
| 290 |
+
f"\nOrder ALL {len(queue)} PR IDs by review urgency."
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def fmt_hard(obs: Dict) -> str:
|
| 295 |
+
files_text = ""
|
| 296 |
+
for f in obs.get("files", []):
|
| 297 |
+
files_text += (
|
| 298 |
+
f"\n--- {f.get('filename','?')} ---\n"
|
| 299 |
+
f"{f.get('diff','')}\n"
|
| 300 |
+
)
|
| 301 |
+
prev = obs.get("existing_comments", [])
|
| 302 |
+
prev_text = ""
|
| 303 |
+
if prev:
|
| 304 |
+
prev_text = "\nYour previous comments:\n" + "\n".join(f" - {c}" for c in prev)
|
| 305 |
+
return (
|
| 306 |
+
f"PR: {obs.get('pr_id','?')}\n"
|
| 307 |
+
f"Title: {obs.get('title','?')}\n"
|
| 308 |
+
f"Description: {obs.get('description','?')}\n"
|
| 309 |
+
f"Author: {obs.get('author_experience','?')}\n"
|
| 310 |
+
f"{files_text}{prev_text}\n"
|
| 311 |
+
"Review this code. Add comments for bugs. Then approve or request_changes."
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# ─── Task runners ─────────────────────────────────────────────────────────────
|
| 316 |
+
|
| 317 |
+
def run_easy(client: OpenAI, seed: int) -> Tuple[float, List[float], List[Dict]]:
|
| 318 |
+
print(f"\n [EASY] Running episode with seed={seed} via WebSocket...")
|
| 319 |
+
|
| 320 |
+
step_rewards, log = [], []
|
| 321 |
+
|
| 322 |
+
def step_fn(obs: Dict, step: int) -> Optional[Dict]:
|
| 323 |
+
prompt = fmt_easy(obs)
|
| 324 |
+
raw = call_llm(client, PROMPTS["easy"], prompt)
|
| 325 |
+
parsed = parse_json(raw)
|
| 326 |
+
|
| 327 |
+
if parsed and parsed.get("severity"):
|
| 328 |
+
action = {"action_type": "label_severity", "severity": parsed["severity"]}
|
| 329 |
+
else:
|
| 330 |
+
action = {"action_type": "label_severity", "severity": "medium"}
|
| 331 |
+
|
| 332 |
+
return action
|
| 333 |
+
|
| 334 |
+
results = asyncio.run(ws_episode("easy", seed, step_fn))
|
| 335 |
+
|
| 336 |
+
for r in results:
|
| 337 |
+
reward = r["reward"]
|
| 338 |
+
step = r["step"]
|
| 339 |
+
obs = r["obs"]
|
| 340 |
+
action = r["action"]
|
| 341 |
+
|
| 342 |
+
# Extract truth from the observation's info field
|
| 343 |
+
info = obs.get("info") or {}
|
| 344 |
+
truth = (info.get("ground_truth") or {}).get("severity", "?")
|
| 345 |
+
predicted = action.get("severity", "?")
|
| 346 |
+
|
| 347 |
+
step_rewards.append(reward)
|
| 348 |
+
log.append({
|
| 349 |
+
"step": step + 1,
|
| 350 |
+
"predicted": predicted,
|
| 351 |
+
"truth": truth,
|
| 352 |
+
"reward": round(reward, 4),
|
| 353 |
+
})
|
| 354 |
+
|
| 355 |
+
if DEBUG:
|
| 356 |
+
row = log[-1]
|
| 357 |
+
print(f" step={row['step']} pred={row['predicted']:8s} "
|
| 358 |
+
f"truth={row['truth']:8s} r={row['reward']:.3f}")
|
| 359 |
+
|
| 360 |
+
mean = statistics.mean(step_rewards) if step_rewards else 0.0
|
| 361 |
+
return mean, step_rewards, log
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def run_medium(client: OpenAI, seed: int) -> Tuple[float, List[float], List[Dict]]:
|
| 365 |
+
print(f"\n [MEDIUM] Running episode with seed={seed} via WebSocket...")
|
| 366 |
+
|
| 367 |
+
step_rewards, log = [], []
|
| 368 |
+
|
| 369 |
+
def step_fn(obs: Dict, step: int) -> Optional[Dict]:
|
| 370 |
+
queue = obs.get("review_queue", [])
|
| 371 |
+
if not queue:
|
| 372 |
+
queue = [obs.get("pr_id", "PR-001")]
|
| 373 |
+
|
| 374 |
+
prompt = fmt_medium(obs)
|
| 375 |
+
raw = call_llm(client, PROMPTS["medium"], prompt)
|
| 376 |
+
parsed = parse_json(raw)
|
| 377 |
+
|
| 378 |
+
if parsed and parsed.get("priority_order"):
|
| 379 |
+
order = parsed["priority_order"]
|
| 380 |
+
# Fill any missing IDs at the end
|
| 381 |
+
for qid in queue:
|
| 382 |
+
if qid not in order:
|
| 383 |
+
order.append(qid)
|
| 384 |
+
order = [q for q in order if q in queue] or queue
|
| 385 |
+
action = {"action_type": "prioritize", "priority_order": order}
|
| 386 |
+
else:
|
| 387 |
+
action = {"action_type": "prioritize", "priority_order": queue}
|
| 388 |
+
|
| 389 |
+
return action
|
| 390 |
+
|
| 391 |
+
results = asyncio.run(ws_episode("medium", seed, step_fn))
|
| 392 |
+
|
| 393 |
+
for r in results:
|
| 394 |
+
reward = r["reward"]
|
| 395 |
+
step = r["step"]
|
| 396 |
+
obs = r["obs"]
|
| 397 |
+
action = r["action"]
|
| 398 |
+
|
| 399 |
+
info = obs.get("info") or {}
|
| 400 |
+
truth_order = (
|
| 401 |
+
info.get("ground_truth_order")
|
| 402 |
+
or (info.get("ground_truth") or {}).get("priority_order")
|
| 403 |
+
or []
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
step_rewards.append(reward)
|
| 407 |
+
log.append({
|
| 408 |
+
"step": step + 1,
|
| 409 |
+
"predicted_order": action.get("priority_order", []),
|
| 410 |
+
"truth_order": truth_order,
|
| 411 |
+
"kendall_tau": info.get("kendall_tau", "?"),
|
| 412 |
+
"reward": round(reward, 4),
|
| 413 |
+
})
|
| 414 |
+
|
| 415 |
+
if DEBUG:
|
| 416 |
+
row = log[-1]
|
| 417 |
+
print(f" step={row['step']} tau={row['kendall_tau']} pred={row['predicted_order']} "
|
| 418 |
+
f"truth={row['truth_order']} r={row['reward']:.3f}")
|
| 419 |
+
|
| 420 |
+
mean = statistics.mean(step_rewards) if step_rewards else 0.0
|
| 421 |
+
return mean, step_rewards, log
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def run_hard(client: OpenAI, seed: int) -> Tuple[float, List[float], List[Dict]]:
|
| 425 |
+
print(f"\n [HARD] Running episode with seed={seed} via WebSocket...")
|
| 426 |
+
|
| 427 |
+
step_rewards, log = [], []
|
| 428 |
+
comments_sent: Dict[str, int] = {}
|
| 429 |
+
pr_finalized: set = set()
|
| 430 |
+
MAX_COMMENTS_PER_PR = 3
|
| 431 |
+
|
| 432 |
+
def step_fn(obs: Dict, step: int) -> Optional[Dict]:
|
| 433 |
+
current_pr = obs.get("pr_id", "?")
|
| 434 |
+
|
| 435 |
+
# If this PR is already finalized, the server stuck — stop
|
| 436 |
+
if current_pr in pr_finalized:
|
| 437 |
+
if DEBUG:
|
| 438 |
+
print(f" [WARN] Already finalized {current_pr}, server stuck — stopping")
|
| 439 |
+
return None
|
| 440 |
+
|
| 441 |
+
comments_sent.setdefault(current_pr, 0)
|
| 442 |
+
n_comments = comments_sent[current_pr]
|
| 443 |
+
|
| 444 |
+
prompt = fmt_hard(obs)
|
| 445 |
+
raw = call_llm(client, PROMPTS["hard"], prompt)
|
| 446 |
+
parsed = parse_json(raw)
|
| 447 |
+
|
| 448 |
+
# Build action — force decision if we've already sent enough comments
|
| 449 |
+
if n_comments >= MAX_COMMENTS_PER_PR:
|
| 450 |
+
action = {"action_type": "request_changes"}
|
| 451 |
+
elif parsed:
|
| 452 |
+
atype = parsed.get("action_type", "")
|
| 453 |
+
if atype == "add_comment":
|
| 454 |
+
action = {
|
| 455 |
+
"action_type": "add_comment",
|
| 456 |
+
"comment": parsed.get("comment", "Consider fixing this issue."),
|
| 457 |
+
"target_file": parsed.get("target_file", "unknown"),
|
| 458 |
+
"target_line": int(parsed.get("target_line") or 1),
|
| 459 |
+
}
|
| 460 |
+
elif atype in ("approve", "request_changes"):
|
| 461 |
+
action = {"action_type": atype}
|
| 462 |
+
else:
|
| 463 |
+
action = {"action_type": "request_changes"}
|
| 464 |
+
else:
|
| 465 |
+
action = {"action_type": "request_changes"}
|
| 466 |
+
|
| 467 |
+
# Track comment count
|
| 468 |
+
if action["action_type"] == "add_comment":
|
| 469 |
+
comments_sent[current_pr] += 1
|
| 470 |
+
else:
|
| 471 |
+
pr_finalized.add(current_pr)
|
| 472 |
+
|
| 473 |
+
return action
|
| 474 |
+
|
| 475 |
+
results = asyncio.run(ws_episode("hard", seed, step_fn))
|
| 476 |
+
|
| 477 |
+
for r in results:
|
| 478 |
+
reward = r["reward"]
|
| 479 |
+
step = r["step"]
|
| 480 |
+
action = r["action"]
|
| 481 |
+
obs = r["obs"]
|
| 482 |
+
current_pr = obs.get("pr_id", "?")
|
| 483 |
+
|
| 484 |
+
step_rewards.append(reward)
|
| 485 |
+
log.append({
|
| 486 |
+
"step": step + 1,
|
| 487 |
+
"pr": current_pr,
|
| 488 |
+
"action": action.get("action_type", "?"),
|
| 489 |
+
"comments_sent": comments_sent.get(current_pr, 0),
|
| 490 |
+
"reward": round(reward, 4),
|
| 491 |
+
})
|
| 492 |
+
|
| 493 |
+
if DEBUG:
|
| 494 |
+
row = log[-1]
|
| 495 |
+
print(f" step={row['step']} pr={row['pr']} "
|
| 496 |
+
f"action={row['action']:20s} comments={row['comments_sent']} r={row['reward']:.3f}")
|
| 497 |
+
|
| 498 |
+
# Score on PR-level rewards only (skip comment ack 0.05s)
|
| 499 |
+
pr_rewards = [r for r in step_rewards if abs(r - 0.05) > 0.01]
|
| 500 |
+
mean = statistics.mean(pr_rewards) if pr_rewards else 0.0
|
| 501 |
+
return mean, step_rewards, log
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
# ─── Report ───────────────────────────────────────────────────────────────────
|
| 505 |
+
|
| 506 |
+
def print_step_table(task: str, logs: List[Dict]):
|
| 507 |
+
"""Print a human-readable per-step breakdown."""
|
| 508 |
+
print(f"\n {'Step':<6}", end="")
|
| 509 |
+
if task == "easy":
|
| 510 |
+
print(f"{'Predicted':>10} {'Truth':>10} {'Reward':>8}")
|
| 511 |
+
print(" " + "-" * 36)
|
| 512 |
+
for row in logs:
|
| 513 |
+
match = "✓" if row.get("predicted") == row.get("truth") else "✗"
|
| 514 |
+
print(f" {row['step']:<6} {row.get('predicted','?'):>10} "
|
| 515 |
+
f"{row.get('truth','?'):>10} {row.get('reward',0):>8.3f} {match}")
|
| 516 |
+
elif task == "medium":
|
| 517 |
+
print(f"{'Tau':>6} {'Reward':>8}")
|
| 518 |
+
print(" " + "-" * 26)
|
| 519 |
+
for row in logs:
|
| 520 |
+
tau = row.get('kendall_tau', '?')
|
| 521 |
+
tau_str = f"{tau:.3f}" if isinstance(tau, float) else str(tau)
|
| 522 |
+
print(f" {row['step']:<6} {tau_str:>6} {row.get('reward',0):>8.3f}")
|
| 523 |
+
# Print the orderings on the next line
|
| 524 |
+
pred = row.get('predicted_order', [])
|
| 525 |
+
truth = row.get('truth_order', [])
|
| 526 |
+
if truth:
|
| 527 |
+
print(f" pred : {pred}")
|
| 528 |
+
print(f" truth: {truth}")
|
| 529 |
+
else:
|
| 530 |
+
print(f"{'PR':>8} {'Comments':>9} {'Action':>20} {'Reward':>8}")
|
| 531 |
+
print(" " + "-" * 52)
|
| 532 |
+
for row in logs:
|
| 533 |
+
print(f" {row['step']:<6} {row.get('pr','?'):>8} "
|
| 534 |
+
f"{row.get('comments_sent',0):>9} "
|
| 535 |
+
f"{row.get('action','?'):>20} {row.get('reward',0):>8.3f}")
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
# ─── Main ─────────────────────────────────────────────────────────────────────
|
| 539 |
+
|
| 540 |
+
def main():
|
| 541 |
+
global MODEL_NAME, SEED, DEBUG # allow args to override module-level defaults
|
| 542 |
+
|
| 543 |
+
parser = argparse.ArgumentParser(description="CodeReviewEnv live evaluation")
|
| 544 |
+
parser.add_argument("--task", default="all", choices=["all", "easy", "medium", "hard"])
|
| 545 |
+
parser.add_argument("--model", default=MODEL_NAME)
|
| 546 |
+
parser.add_argument("--seed", default=SEED, type=int)
|
| 547 |
+
parser.add_argument("--eps", default=3, type=int, help="Episodes per task")
|
| 548 |
+
parser.add_argument("--debug", action="store_true")
|
| 549 |
+
args = parser.parse_args()
|
| 550 |
+
|
| 551 |
+
MODEL_NAME = args.model
|
| 552 |
+
SEED = args.seed
|
| 553 |
+
DEBUG = args.debug or DEBUG
|
| 554 |
+
|
| 555 |
+
tasks = TASKS if args.task == "all" else [args.task]
|
| 556 |
+
|
| 557 |
+
# ── Banner ────────────────────────────────────────────────────────
|
| 558 |
+
print("=" * 64)
|
| 559 |
+
print(" CodeReviewEnv — Live Evaluation (WebSocket + Real LLM)")
|
| 560 |
+
print("=" * 64)
|
| 561 |
+
print(f" Space URL : {ENV_BASE_URL}")
|
| 562 |
+
print(f" WS URL : {_ws_url()}")
|
| 563 |
+
print(f" LLM API : {API_BASE_URL}")
|
| 564 |
+
print(f" Model : {MODEL_NAME}")
|
| 565 |
+
print(f" Tasks : {', '.join(tasks)}")
|
| 566 |
+
print(f" Episodes : {args.eps} per task | Seed: {SEED}")
|
| 567 |
+
print("=" * 64)
|
| 568 |
+
|
| 569 |
+
# ── Guard-rails ───────────────────────────────────────────────────
|
| 570 |
+
if not API_KEY:
|
| 571 |
+
print("\n[ERROR] No API key — set OPENAI_API_KEY (or HF_TOKEN / API_KEY)")
|
| 572 |
+
sys.exit(1)
|
| 573 |
+
|
| 574 |
+
print("\n[1/3] Checking live Space health...")
|
| 575 |
+
if not env_health():
|
| 576 |
+
print(" [FAIL] Space is not healthy. Check https://hf.co/spaces/ragavrida/code-review-env")
|
| 577 |
+
sys.exit(1)
|
| 578 |
+
print(" [OK] Space is healthy ✓")
|
| 579 |
+
|
| 580 |
+
client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 581 |
+
|
| 582 |
+
results: Dict[str, Any] = {}
|
| 583 |
+
start = time.time()
|
| 584 |
+
|
| 585 |
+
runners = {"easy": run_easy, "medium": run_medium, "hard": run_hard}
|
| 586 |
+
|
| 587 |
+
for task in tasks:
|
| 588 |
+
label = task.upper()
|
| 589 |
+
runner = runners[task]
|
| 590 |
+
print(f"\n{'─'*64}")
|
| 591 |
+
print(f" Task: {label}")
|
| 592 |
+
print(f"{'─'*64}")
|
| 593 |
+
|
| 594 |
+
scores, all_logs = [], []
|
| 595 |
+
for ep in range(args.eps):
|
| 596 |
+
ep_seed = SEED + ep
|
| 597 |
+
score, steps, log = runner(client, ep_seed)
|
| 598 |
+
scores.append(score)
|
| 599 |
+
all_logs.append(log)
|
| 600 |
+
print(f"\n Episode {ep+1} (seed={ep_seed}) → mean reward = {score:.4f}")
|
| 601 |
+
print_step_table(task, log)
|
| 602 |
+
|
| 603 |
+
mean = statistics.mean(scores) if scores else 0.0
|
| 604 |
+
std = statistics.stdev(scores) if len(scores) > 1 else 0.0
|
| 605 |
+
print(f"\n ── {label} Summary: {mean:.4f} ± {std:.4f} ──")
|
| 606 |
+
results[task] = {"mean": mean, "std": std, "scores": scores, "logs": all_logs}
|
| 607 |
+
|
| 608 |
+
# ── Final summary ─────────────────────────────────────────────────
|
| 609 |
+
elapsed = time.time() - start
|
| 610 |
+
all_means = [results[t]["mean"] for t in tasks]
|
| 611 |
+
composite = statistics.mean(all_means) if all_means else 0.0
|
| 612 |
+
|
| 613 |
+
print(f"\n{'='*64}")
|
| 614 |
+
print(" FINAL RESULTS")
|
| 615 |
+
print(f"{'='*64}")
|
| 616 |
+
print(f" {'Task':<12} {'Score':>8} {'Std':>8}")
|
| 617 |
+
print(" " + "-" * 30)
|
| 618 |
+
for task in tasks:
|
| 619 |
+
r = results[task]
|
| 620 |
+
print(f" {task.capitalize():<12} {r['mean']:>8.4f} {r['std']:>8.4f}")
|
| 621 |
+
if len(tasks) > 1:
|
| 622 |
+
print(" " + "-" * 30)
|
| 623 |
+
print(f" {'Composite':<12} {composite:>8.4f}")
|
| 624 |
+
print(f"\n Model: {MODEL_NAME} | Time: {elapsed:.1f}s")
|
| 625 |
+
print(f"{'='*64}")
|
| 626 |
+
|
| 627 |
+
# ── Save results ──────────────────────────────────────────────────
|
| 628 |
+
out = {
|
| 629 |
+
"model": MODEL_NAME,
|
| 630 |
+
"space_url": ENV_BASE_URL,
|
| 631 |
+
"seed": SEED,
|
| 632 |
+
"episodes": args.eps,
|
| 633 |
+
"tasks": {t: {"mean": results[t]["mean"], "std": results[t]["std"],
|
| 634 |
+
"scores": results[t]["scores"]} for t in tasks},
|
| 635 |
+
"composite": composite,
|
| 636 |
+
"elapsed_s": round(elapsed, 1),
|
| 637 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 638 |
+
}
|
| 639 |
+
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
| 640 |
+
"baseline", "live_results.json")
|
| 641 |
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 642 |
+
with open(out_path, "w") as f:
|
| 643 |
+
json.dump(out, f, indent=2)
|
| 644 |
+
print(f"\n Results saved → {out_path}")
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
if __name__ == "__main__":
|
| 648 |
+
main()
|
graders/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Graders for CodeReviewEnv — fully deterministic, no LLM calls.
|
| 3 |
+
|
| 4 |
+
All graders produce scores in [-1.0, 1.0] and never crash on
|
| 5 |
+
malformed input. Invalid actions receive penalty scores.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from graders.grader_easy import EasyGrader
|
| 9 |
+
from graders.grader_medium import MediumGrader
|
| 10 |
+
from graders.grader_hard import HardGrader
|
| 11 |
+
|
| 12 |
+
__all__ = ["EasyGrader", "MediumGrader", "HardGrader"]
|
graders/grader_easy.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Easy Grader — Severity Labeling Scorer
|
| 3 |
+
|
| 4 |
+
Scores agent's ability to correctly label PR bug severity.
|
| 5 |
+
Fully deterministic, no LLM calls, never crashes on malformed input.
|
| 6 |
+
|
| 7 |
+
Scoring formula:
|
| 8 |
+
exact_match: 1.0 if predicted == ground_truth
|
| 9 |
+
adjacent_match: 0.5 if off by one level on the ordinal scale
|
| 10 |
+
(critical↔high, high↔medium, medium↔low, low↔none)
|
| 11 |
+
wrong: 0.0 for all other mismatches
|
| 12 |
+
|
| 13 |
+
Exploit prevention penalties (applied on top of base score):
|
| 14 |
+
critical missed as "none": -0.3 extra
|
| 15 |
+
critical missed as "low": -0.2 extra
|
| 16 |
+
|
| 17 |
+
These penalties reflect real-world cost: missing a critical security
|
| 18 |
+
bug during code review has outsized negative impact. The asymmetric
|
| 19 |
+
penalty structure encodes domain knowledge about review risk.
|
| 20 |
+
|
| 21 |
+
Research metrics returned in info dict:
|
| 22 |
+
- confusion_matrix: 5×5 severity classification matrix
|
| 23 |
+
- severity_bias: signed mean prediction error (positive = over-labeling)
|
| 24 |
+
- critical_recall: fraction of critical bugs correctly identified
|
| 25 |
+
- false_critical_rate: fraction of non-critical labeled as critical
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from typing import Dict, List, Optional, Tuple
|
| 29 |
+
from env.models import Action, Reward
|
| 30 |
+
from env.data_generator import SEVERITY_ORDER, get_ground_truth
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class EasyGrader:
|
| 34 |
+
"""
|
| 35 |
+
Deterministic grader for severity labeling (easy task).
|
| 36 |
+
|
| 37 |
+
Implements ordinal scoring with adjacency bonus and asymmetric
|
| 38 |
+
penalty for missed critical bugs — a design choice reflecting
|
| 39 |
+
the real-world cost structure of code review.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __init__(self):
|
| 43 |
+
# Severity levels in order: index 0 = most severe
|
| 44 |
+
self.severity_levels = SEVERITY_ORDER # ["critical", "high", "medium", "low", "none"]
|
| 45 |
+
self.severity_index = {s: i for i, s in enumerate(self.severity_levels)}
|
| 46 |
+
|
| 47 |
+
# Tracking for research metrics across episode
|
| 48 |
+
self.predictions: List[str] = []
|
| 49 |
+
self.ground_truths: List[str] = []
|
| 50 |
+
|
| 51 |
+
def reset(self) -> None:
|
| 52 |
+
"""Reset episode-level tracking for research metrics."""
|
| 53 |
+
self.predictions = []
|
| 54 |
+
self.ground_truths = []
|
| 55 |
+
|
| 56 |
+
def grade(self, action: Action, pr_id: str) -> Tuple[Reward, Dict]:
|
| 57 |
+
"""
|
| 58 |
+
Grade a single severity labeling action.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
action: Agent's action (must have action_type="label_severity")
|
| 62 |
+
pr_id: The PR being graded
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
(Reward, info_dict) where info_dict contains research metrics
|
| 66 |
+
"""
|
| 67 |
+
gt = get_ground_truth(pr_id)
|
| 68 |
+
true_severity = gt["ground_truth_severity"]
|
| 69 |
+
breakdown: Dict[str, float] = {}
|
| 70 |
+
|
| 71 |
+
# ── Validate action ──────────────────────────────────────────
|
| 72 |
+
if action.action_type != "label_severity" or action.severity is None:
|
| 73 |
+
self.predictions.append("none")
|
| 74 |
+
self.ground_truths.append(true_severity)
|
| 75 |
+
breakdown["step_reward"] = 0.0
|
| 76 |
+
breakdown["critical_penalty"] = -0.3 if true_severity == "critical" else 0.0
|
| 77 |
+
total = max(-1.0, min(1.0, sum(breakdown.values())))
|
| 78 |
+
return Reward(
|
| 79 |
+
value=total,
|
| 80 |
+
breakdown=breakdown,
|
| 81 |
+
reason=f"Invalid action for severity labeling. Expected label_severity with severity field.",
|
| 82 |
+
), self._build_info(true_severity, "none")
|
| 83 |
+
|
| 84 |
+
predicted = action.severity
|
| 85 |
+
self.predictions.append(predicted)
|
| 86 |
+
self.ground_truths.append(true_severity)
|
| 87 |
+
|
| 88 |
+
# ── Compute base score ───────────────────────────────────────
|
| 89 |
+
pred_idx = self.severity_index.get(predicted, -1)
|
| 90 |
+
true_idx = self.severity_index.get(true_severity, -1)
|
| 91 |
+
|
| 92 |
+
if pred_idx == -1 or true_idx == -1:
|
| 93 |
+
# Unknown severity label — score 0
|
| 94 |
+
breakdown["step_reward"] = 0.0
|
| 95 |
+
elif predicted == true_severity:
|
| 96 |
+
# Exact match — full score
|
| 97 |
+
breakdown["step_reward"] = 1.0
|
| 98 |
+
elif abs(pred_idx - true_idx) == 1:
|
| 99 |
+
# Adjacent match — half score
|
| 100 |
+
# Empirically, one-level-off is a reasonable disagreement
|
| 101 |
+
# (human inter-rater agreement on severity is ~0.7 Cohen's κ)
|
| 102 |
+
breakdown["step_reward"] = 0.5
|
| 103 |
+
else:
|
| 104 |
+
# Wrong by 2+ levels
|
| 105 |
+
breakdown["step_reward"] = 0.0
|
| 106 |
+
|
| 107 |
+
# ── Exploit prevention penalties ─────────────────────────────
|
| 108 |
+
# Asymmetric: missing critical is penalized more heavily because
|
| 109 |
+
# false negatives on security bugs have higher real-world cost
|
| 110 |
+
# than false positives (which only waste reviewer time)
|
| 111 |
+
breakdown["critical_penalty"] = 0.0
|
| 112 |
+
if true_severity == "critical":
|
| 113 |
+
if predicted == "none":
|
| 114 |
+
breakdown["critical_penalty"] = -0.3 # Most dangerous miss
|
| 115 |
+
elif predicted == "low":
|
| 116 |
+
breakdown["critical_penalty"] = -0.2 # Still very bad
|
| 117 |
+
|
| 118 |
+
total = max(-1.0, min(1.0, sum(breakdown.values())))
|
| 119 |
+
reason = f"Predicted: {predicted}, Truth: {true_severity}"
|
| 120 |
+
if breakdown["critical_penalty"] < 0:
|
| 121 |
+
reason += f" (critical miss penalty: {breakdown['critical_penalty']})"
|
| 122 |
+
|
| 123 |
+
return Reward(
|
| 124 |
+
value=total,
|
| 125 |
+
breakdown=breakdown,
|
| 126 |
+
reason=reason,
|
| 127 |
+
), self._build_info(true_severity, predicted)
|
| 128 |
+
|
| 129 |
+
def _build_info(self, true_severity: str, predicted: str) -> Dict:
|
| 130 |
+
"""Build research-grade info dict with classification metrics."""
|
| 131 |
+
info: Dict = {
|
| 132 |
+
"true_severity": true_severity,
|
| 133 |
+
"predicted_severity": predicted,
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
# Compute confusion matrix from all predictions so far
|
| 137 |
+
info["confusion_matrix"] = self._confusion_matrix()
|
| 138 |
+
info["severity_bias"] = self._severity_bias()
|
| 139 |
+
info["critical_recall"] = self._critical_recall()
|
| 140 |
+
info["false_critical_rate"] = self._false_critical_rate()
|
| 141 |
+
|
| 142 |
+
return info
|
| 143 |
+
|
| 144 |
+
def _confusion_matrix(self) -> Dict[str, Dict[str, int]]:
|
| 145 |
+
"""
|
| 146 |
+
5×5 confusion matrix: matrix[true][predicted] = count.
|
| 147 |
+
Enables detailed error analysis beyond aggregate scores.
|
| 148 |
+
"""
|
| 149 |
+
matrix = {s: {p: 0 for p in self.severity_levels} for s in self.severity_levels}
|
| 150 |
+
for true, pred in zip(self.ground_truths, self.predictions):
|
| 151 |
+
if true in matrix and pred in matrix[true]:
|
| 152 |
+
matrix[true][pred] += 1
|
| 153 |
+
return matrix
|
| 154 |
+
|
| 155 |
+
def _severity_bias(self) -> float:
|
| 156 |
+
"""
|
| 157 |
+
Signed mean prediction error on ordinal scale.
|
| 158 |
+
Positive = over-labeling (predicting more severe than truth).
|
| 159 |
+
Negative = under-labeling (predicting less severe than truth).
|
| 160 |
+
|
| 161 |
+
Computed as: mean(true_index - pred_index)
|
| 162 |
+
Since index 0 = critical (most severe), positive bias means
|
| 163 |
+
the agent tends to predict less severe (higher index) than truth.
|
| 164 |
+
"""
|
| 165 |
+
if not self.predictions:
|
| 166 |
+
return 0.0
|
| 167 |
+
errors = []
|
| 168 |
+
for true, pred in zip(self.ground_truths, self.predictions):
|
| 169 |
+
t_idx = self.severity_index.get(true, 2)
|
| 170 |
+
p_idx = self.severity_index.get(pred, 2)
|
| 171 |
+
errors.append(p_idx - t_idx)
|
| 172 |
+
return sum(errors) / len(errors)
|
| 173 |
+
|
| 174 |
+
def _critical_recall(self) -> float:
|
| 175 |
+
"""
|
| 176 |
+
Fraction of critical bugs correctly identified.
|
| 177 |
+
critical_recall = TP_critical / (TP_critical + FN_critical)
|
| 178 |
+
"""
|
| 179 |
+
total_critical = sum(1 for t in self.ground_truths if t == "critical")
|
| 180 |
+
if total_critical == 0:
|
| 181 |
+
return 1.0 # No critical bugs — perfect recall by default
|
| 182 |
+
caught = sum(1 for t, p in zip(self.ground_truths, self.predictions) if t == "critical" and p == "critical")
|
| 183 |
+
return caught / total_critical
|
| 184 |
+
|
| 185 |
+
def _false_critical_rate(self) -> float:
|
| 186 |
+
"""
|
| 187 |
+
Fraction of non-critical PRs labeled as critical.
|
| 188 |
+
false_critical_rate = FP_critical / total_non_critical
|
| 189 |
+
"""
|
| 190 |
+
non_critical = sum(1 for t in self.ground_truths if t != "critical")
|
| 191 |
+
if non_critical == 0:
|
| 192 |
+
return 0.0
|
| 193 |
+
false_critical = sum(1 for t, p in zip(self.ground_truths, self.predictions) if t != "critical" and p == "critical")
|
| 194 |
+
return false_critical / non_critical
|
| 195 |
+
|
| 196 |
+
def analyze_failure_modes(self) -> Dict:
|
| 197 |
+
"""
|
| 198 |
+
Analyze common failure patterns in agent predictions.
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
missed_critical: count of critical bugs not labeled critical
|
| 202 |
+
vague_labels: count of "none" predictions for bugs with severity >= medium
|
| 203 |
+
over_labeled: count of non-bugs labeled as high or critical
|
| 204 |
+
"""
|
| 205 |
+
missed_critical = sum(
|
| 206 |
+
1 for t, p in zip(self.ground_truths, self.predictions)
|
| 207 |
+
if t == "critical" and p != "critical"
|
| 208 |
+
)
|
| 209 |
+
vague_labels = sum(
|
| 210 |
+
1 for t, p in zip(self.ground_truths, self.predictions)
|
| 211 |
+
if t in ("critical", "high", "medium") and p == "none"
|
| 212 |
+
)
|
| 213 |
+
over_labeled = sum(
|
| 214 |
+
1 for t, p in zip(self.ground_truths, self.predictions)
|
| 215 |
+
if t in ("low", "none") and p in ("critical", "high")
|
| 216 |
+
)
|
| 217 |
+
return {
|
| 218 |
+
"missed_critical": missed_critical,
|
| 219 |
+
"vague_labels": vague_labels,
|
| 220 |
+
"over_labeled": over_labeled,
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
def episode_score(self, step_rewards: List[float]) -> float:
|
| 224 |
+
"""
|
| 225 |
+
Compute episode-level score as mean of step rewards.
|
| 226 |
+
|
| 227 |
+
This is the aggregate metric for the easy task.
|
| 228 |
+
Each step reward is already in [-1, 1], so mean is also in [-1, 1].
|
| 229 |
+
"""
|
| 230 |
+
if not step_rewards:
|
| 231 |
+
return 0.0
|
| 232 |
+
return sum(step_rewards) / len(step_rewards)
|
graders/grader_hard.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hard Grader — Actionable Feedback Generation Scorer
|
| 3 |
+
|
| 4 |
+
Scores agent's code review comments across 5 dimensions:
|
| 5 |
+
relevance (0.25 weight): comments target actual bug locations (±5 lines)
|
| 6 |
+
specificity (0.20 weight): comments mention bug-category-specific keywords
|
| 7 |
+
actionability (0.20 weight): comments contain concrete suggestions
|
| 8 |
+
coverage (0.25 weight): critical and high bugs have relevant comments
|
| 9 |
+
precision (0.10 weight): comments don't target non-bug locations
|
| 10 |
+
|
| 11 |
+
All scoring is fully deterministic via keyword matching and line proximity —
|
| 12 |
+
no LLM calls, no randomness. This ensures reproducible evaluation across
|
| 13 |
+
different hardware and runtimes.
|
| 14 |
+
|
| 15 |
+
Exploit prevention:
|
| 16 |
+
>10 comments per PR: precision denominator doubles (spam penalty)
|
| 17 |
+
approve without comments: score = 0.0 flat
|
| 18 |
+
request_changes without comments: score = 0.0 flat
|
| 19 |
+
approve with unaddressed critical bug: -0.5 episode penalty
|
| 20 |
+
|
| 21 |
+
Weight rationale (empirically calibrated):
|
| 22 |
+
Relevance + Coverage = 0.50: catching real bugs is the primary goal
|
| 23 |
+
Specificity + Actionability = 0.40: review quality matters
|
| 24 |
+
Precision = 0.10: false positives waste time but are less harmful
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from typing import Dict, List, Tuple, Optional
|
| 28 |
+
from env.models import Action, Reward
|
| 29 |
+
from env.data_generator import get_ground_truth, BUG_KEYWORDS, ACTIONABILITY_KEYWORDS
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class HardGrader:
|
| 33 |
+
"""
|
| 34 |
+
Deterministic grader for feedback generation (hard task).
|
| 35 |
+
|
| 36 |
+
Five-component weighted scoring with exploit prevention.
|
| 37 |
+
Designed to be genuinely hard — GPT-4o-mini scores ~0.41,
|
| 38 |
+
reflecting the difficulty of generating precise, actionable
|
| 39 |
+
code review feedback targeting specific bug locations.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
# Component weights — sum to 1.0
|
| 43 |
+
# Coverage and relevance weighted highest because they measure
|
| 44 |
+
# the fundamental goal: finding and targeting real bugs
|
| 45 |
+
W_RELEVANCE = 0.25
|
| 46 |
+
W_SPECIFICITY = 0.20
|
| 47 |
+
W_ACTIONABILITY = 0.20
|
| 48 |
+
W_COVERAGE = 0.25
|
| 49 |
+
W_PRECISION = 0.10
|
| 50 |
+
|
| 51 |
+
# Line proximity tolerance: ±5 lines counts as "relevant"
|
| 52 |
+
# Based on empirical code review: reviewers often reference
|
| 53 |
+
# nearby context lines rather than the exact bug line
|
| 54 |
+
LINE_TOLERANCE = 5
|
| 55 |
+
|
| 56 |
+
# Spam threshold: more than this many comments triggers penalty
|
| 57 |
+
SPAM_THRESHOLD = 10
|
| 58 |
+
|
| 59 |
+
def __init__(self):
|
| 60 |
+
self.episode_comments: Dict[str, List[Action]] = {} # pr_id → comments
|
| 61 |
+
self.episode_decisions: Dict[str, str] = {} # pr_id → approve/request_changes
|
| 62 |
+
self.episode_penalties: float = 0.0
|
| 63 |
+
|
| 64 |
+
def reset(self) -> None:
|
| 65 |
+
"""Reset episode-level tracking."""
|
| 66 |
+
self.episode_comments = {}
|
| 67 |
+
self.episode_decisions = {}
|
| 68 |
+
self.episode_penalties = 0.0
|
| 69 |
+
|
| 70 |
+
def add_comment(self, pr_id: str, action: Action) -> None:
|
| 71 |
+
"""Track a comment action for later scoring."""
|
| 72 |
+
if pr_id not in self.episode_comments:
|
| 73 |
+
self.episode_comments[pr_id] = []
|
| 74 |
+
self.episode_comments[pr_id].append(action)
|
| 75 |
+
|
| 76 |
+
def grade_pr(self, pr_id: str, decision: str) -> Tuple[Reward, Dict]:
|
| 77 |
+
"""
|
| 78 |
+
Grade all comments + decision for a single PR.
|
| 79 |
+
|
| 80 |
+
Called when agent submits approve or request_changes.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
pr_id: The PR being reviewed
|
| 84 |
+
decision: "approve" or "request_changes"
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
(Reward, info_dict) with per-component scores and analysis
|
| 88 |
+
"""
|
| 89 |
+
self.episode_decisions[pr_id] = decision
|
| 90 |
+
comments = self.episode_comments.get(pr_id, [])
|
| 91 |
+
gt = get_ground_truth(pr_id)
|
| 92 |
+
bug_lines = gt["bug_lines"]
|
| 93 |
+
bug_category = gt["bug_category"]
|
| 94 |
+
true_severity = gt["ground_truth_severity"]
|
| 95 |
+
breakdown: Dict[str, float] = {}
|
| 96 |
+
|
| 97 |
+
# ── Exploit check: no comments ───────────────────────────────
|
| 98 |
+
if not comments:
|
| 99 |
+
if decision == "approve":
|
| 100 |
+
breakdown["step_reward"] = 0.0
|
| 101 |
+
return Reward(
|
| 102 |
+
value=0.0,
|
| 103 |
+
breakdown=breakdown,
|
| 104 |
+
reason="Approved without any review comments — score 0.0",
|
| 105 |
+
), self._empty_info(pr_id)
|
| 106 |
+
elif decision == "request_changes":
|
| 107 |
+
breakdown["step_reward"] = 0.0
|
| 108 |
+
return Reward(
|
| 109 |
+
value=0.0,
|
| 110 |
+
breakdown=breakdown,
|
| 111 |
+
reason="Requested changes without any comments — score 0.0",
|
| 112 |
+
), self._empty_info(pr_id)
|
| 113 |
+
|
| 114 |
+
total_comments = len(comments)
|
| 115 |
+
|
| 116 |
+
# ── 1. Relevance (0.25): comments target actual bug locations ─
|
| 117 |
+
relevant_count = 0
|
| 118 |
+
for c in comments:
|
| 119 |
+
if c.target_line is not None and bug_lines:
|
| 120 |
+
for bl in bug_lines:
|
| 121 |
+
if abs(c.target_line - bl) <= self.LINE_TOLERANCE:
|
| 122 |
+
relevant_count += 1
|
| 123 |
+
break
|
| 124 |
+
relevance = relevant_count / total_comments if total_comments > 0 else 0.0
|
| 125 |
+
breakdown["relevance"] = relevance
|
| 126 |
+
|
| 127 |
+
# ── 2. Specificity (0.20): comments mention category keywords ─
|
| 128 |
+
keywords = BUG_KEYWORDS.get(bug_category, [])
|
| 129 |
+
specific_count = 0
|
| 130 |
+
for c in comments:
|
| 131 |
+
if c.comment:
|
| 132 |
+
comment_lower = c.comment.lower()
|
| 133 |
+
if any(kw.lower() in comment_lower for kw in keywords):
|
| 134 |
+
specific_count += 1
|
| 135 |
+
specificity = specific_count / total_comments if total_comments > 0 else 0.0
|
| 136 |
+
breakdown["specificity"] = specificity
|
| 137 |
+
|
| 138 |
+
# ── 3. Actionability (0.20): comments suggest concrete fixes ──
|
| 139 |
+
actionable_count = 0
|
| 140 |
+
for c in comments:
|
| 141 |
+
if c.comment:
|
| 142 |
+
comment_lower = c.comment.lower()
|
| 143 |
+
if any(kw in comment_lower for kw in ACTIONABILITY_KEYWORDS):
|
| 144 |
+
actionable_count += 1
|
| 145 |
+
actionability = actionable_count / total_comments if total_comments > 0 else 0.0
|
| 146 |
+
breakdown["actionability"] = actionability
|
| 147 |
+
|
| 148 |
+
# ── 4. Coverage (0.25): critical/high bugs have relevant comments ─
|
| 149 |
+
total_critical = 1 if true_severity == "critical" else 0
|
| 150 |
+
total_high = 1 if true_severity == "high" else 0
|
| 151 |
+
critical_caught = 0
|
| 152 |
+
high_caught = 0
|
| 153 |
+
|
| 154 |
+
if bug_lines:
|
| 155 |
+
for c in comments:
|
| 156 |
+
if c.target_line is not None:
|
| 157 |
+
for bl in bug_lines:
|
| 158 |
+
if abs(c.target_line - bl) <= self.LINE_TOLERANCE:
|
| 159 |
+
if true_severity == "critical":
|
| 160 |
+
critical_caught = 1
|
| 161 |
+
elif true_severity == "high":
|
| 162 |
+
high_caught = 1
|
| 163 |
+
break
|
| 164 |
+
|
| 165 |
+
denom = total_critical + 0.5 * total_high
|
| 166 |
+
if denom > 0:
|
| 167 |
+
coverage = (critical_caught + 0.5 * high_caught) / denom
|
| 168 |
+
else:
|
| 169 |
+
# No critical/high bugs — coverage is perfect by default
|
| 170 |
+
coverage = 1.0
|
| 171 |
+
breakdown["coverage"] = coverage
|
| 172 |
+
|
| 173 |
+
# ── 5. Precision (0.10): avoid false positives ────────────────
|
| 174 |
+
false_positives = 0
|
| 175 |
+
for c in comments:
|
| 176 |
+
if c.target_line is not None:
|
| 177 |
+
is_near_bug = False
|
| 178 |
+
if bug_lines:
|
| 179 |
+
for bl in bug_lines:
|
| 180 |
+
if abs(c.target_line - bl) <= self.LINE_TOLERANCE:
|
| 181 |
+
is_near_bug = True
|
| 182 |
+
break
|
| 183 |
+
if not is_near_bug:
|
| 184 |
+
false_positives += 1
|
| 185 |
+
|
| 186 |
+
# Spam penalty: >10 comments doubles precision denominator
|
| 187 |
+
# This prevents agents from gaming coverage by spamming comments
|
| 188 |
+
effective_total = total_comments
|
| 189 |
+
if total_comments > self.SPAM_THRESHOLD:
|
| 190 |
+
effective_total = total_comments * 2
|
| 191 |
+
|
| 192 |
+
precision = 1.0 - (false_positives / effective_total) if effective_total > 0 else 1.0
|
| 193 |
+
precision = max(0.0, precision)
|
| 194 |
+
breakdown["precision"] = precision
|
| 195 |
+
|
| 196 |
+
# ── Weighted final score ─────────────────────────────────────
|
| 197 |
+
step_score = (
|
| 198 |
+
self.W_RELEVANCE * relevance
|
| 199 |
+
+ self.W_SPECIFICITY * specificity
|
| 200 |
+
+ self.W_ACTIONABILITY * actionability
|
| 201 |
+
+ self.W_COVERAGE * coverage
|
| 202 |
+
+ self.W_PRECISION * precision
|
| 203 |
+
)
|
| 204 |
+
breakdown["step_reward"] = step_score
|
| 205 |
+
|
| 206 |
+
# ── Exploit: approve with unaddressed critical bug ───────────
|
| 207 |
+
breakdown["critical_approve_penalty"] = 0.0
|
| 208 |
+
if decision == "approve" and true_severity == "critical" and critical_caught == 0:
|
| 209 |
+
breakdown["critical_approve_penalty"] = -0.5
|
| 210 |
+
self.episode_penalties += -0.5
|
| 211 |
+
|
| 212 |
+
total = max(-1.0, min(1.0, step_score + breakdown["critical_approve_penalty"]))
|
| 213 |
+
|
| 214 |
+
# Build detailed info
|
| 215 |
+
info = {
|
| 216 |
+
"relevance_score": relevance,
|
| 217 |
+
"specificity_score": specificity,
|
| 218 |
+
"actionability_score": actionability,
|
| 219 |
+
"coverage_score": coverage,
|
| 220 |
+
"precision_score": precision,
|
| 221 |
+
"bugs_caught": self._bugs_caught(comments, gt),
|
| 222 |
+
"bugs_missed": self._bugs_missed(comments, gt),
|
| 223 |
+
"comment_efficiency": relevant_count / total_comments if total_comments > 0 else 0.0,
|
| 224 |
+
"false_positive_rate": false_positives / total_comments if total_comments > 0 else 0.0,
|
| 225 |
+
"total_comments": total_comments,
|
| 226 |
+
"relevant_comments": relevant_count,
|
| 227 |
+
"specific_comments": specific_count,
|
| 228 |
+
"actionable_comments": actionable_count,
|
| 229 |
+
"false_positives": false_positives,
|
| 230 |
+
"decision": decision,
|
| 231 |
+
"true_severity": true_severity,
|
| 232 |
+
"bug_category": bug_category,
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
reason = (
|
| 236 |
+
f"rel={relevance:.2f} spec={specificity:.2f} act={actionability:.2f} "
|
| 237 |
+
f"cov={coverage:.2f} prec={precision:.2f} → {step_score:.3f}"
|
| 238 |
+
)
|
| 239 |
+
if breakdown["critical_approve_penalty"] < 0:
|
| 240 |
+
reason += f" (critical approve penalty: {breakdown['critical_approve_penalty']})"
|
| 241 |
+
|
| 242 |
+
return Reward(value=total, breakdown=breakdown, reason=reason), info
|
| 243 |
+
|
| 244 |
+
def _empty_info(self, pr_id: str) -> Dict:
|
| 245 |
+
"""Return zeroed info dict for invalid actions."""
|
| 246 |
+
gt = get_ground_truth(pr_id)
|
| 247 |
+
return {
|
| 248 |
+
"relevance_score": 0.0,
|
| 249 |
+
"specificity_score": 0.0,
|
| 250 |
+
"actionability_score": 0.0,
|
| 251 |
+
"coverage_score": 0.0,
|
| 252 |
+
"precision_score": 0.0,
|
| 253 |
+
"bugs_caught": {"critical": 0, "high": 0, "medium": 0, "low": 0},
|
| 254 |
+
"bugs_missed": self._all_bugs_as_missed(gt),
|
| 255 |
+
"comment_efficiency": 0.0,
|
| 256 |
+
"false_positive_rate": 0.0,
|
| 257 |
+
"total_comments": 0,
|
| 258 |
+
"relevant_comments": 0,
|
| 259 |
+
"specific_comments": 0,
|
| 260 |
+
"actionable_comments": 0,
|
| 261 |
+
"false_positives": 0,
|
| 262 |
+
"decision": self.episode_decisions.get(pr_id, "none"),
|
| 263 |
+
"true_severity": gt["ground_truth_severity"],
|
| 264 |
+
"bug_category": gt["bug_category"],
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
def _bugs_caught(self, comments: List[Action], gt: Dict) -> Dict[str, int]:
|
| 268 |
+
"""Count bugs caught per severity level."""
|
| 269 |
+
result = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
| 270 |
+
sev = gt["ground_truth_severity"]
|
| 271 |
+
if sev == "none":
|
| 272 |
+
return result
|
| 273 |
+
|
| 274 |
+
bug_lines = gt["bug_lines"]
|
| 275 |
+
caught = False
|
| 276 |
+
for c in comments:
|
| 277 |
+
if c.target_line is not None:
|
| 278 |
+
for bl in bug_lines:
|
| 279 |
+
if abs(c.target_line - bl) <= self.LINE_TOLERANCE:
|
| 280 |
+
caught = True
|
| 281 |
+
break
|
| 282 |
+
if caught:
|
| 283 |
+
break
|
| 284 |
+
|
| 285 |
+
if caught and sev in result:
|
| 286 |
+
result[sev] = 1
|
| 287 |
+
return result
|
| 288 |
+
|
| 289 |
+
def _bugs_missed(self, comments: List[Action], gt: Dict) -> Dict[str, int]:
|
| 290 |
+
"""Count bugs missed per severity level."""
|
| 291 |
+
caught = self._bugs_caught(comments, gt)
|
| 292 |
+
result = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
| 293 |
+
sev = gt["ground_truth_severity"]
|
| 294 |
+
if sev in result and caught.get(sev, 0) == 0:
|
| 295 |
+
result[sev] = 1
|
| 296 |
+
return result
|
| 297 |
+
|
| 298 |
+
def _all_bugs_as_missed(self, gt: Dict) -> Dict[str, int]:
|
| 299 |
+
"""All bugs as missed (for empty comment case)."""
|
| 300 |
+
result = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
| 301 |
+
sev = gt["ground_truth_severity"]
|
| 302 |
+
if sev in result:
|
| 303 |
+
result[sev] = 1
|
| 304 |
+
return result
|
| 305 |
+
|
| 306 |
+
def episode_score(self, step_rewards: List[float]) -> float:
|
| 307 |
+
"""Compute episode-level score with accumulated penalties."""
|
| 308 |
+
if not step_rewards:
|
| 309 |
+
return 0.0
|
| 310 |
+
mean_score = sum(step_rewards) / len(step_rewards)
|
| 311 |
+
return max(-1.0, min(1.0, mean_score + self.episode_penalties))
|
graders/grader_medium.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Medium Grader — Queue Prioritization Scorer
|
| 3 |
+
|
| 4 |
+
Scores agent's ability to order a PR review queue by priority.
|
| 5 |
+
Uses Kendall Tau rank correlation — a standard non-parametric
|
| 6 |
+
measure of ordinal association between two rankings.
|
| 7 |
+
|
| 8 |
+
Scoring formula:
|
| 9 |
+
base_score = (kendall_tau + 1) / 2 # normalize from [-1,1] to [0,1]
|
| 10 |
+
|
| 11 |
+
Exploit prevention penalties:
|
| 12 |
+
-0.3 if any critical PR not in top 2 positions
|
| 13 |
+
-0.2 if security_vulnerability PR not in position 1
|
| 14 |
+
|
| 15 |
+
final_score = max(0.0, base_score + penalties)
|
| 16 |
+
|
| 17 |
+
Priority ordering ground truth:
|
| 18 |
+
1. Security PRs first (sql_injection, security_vulnerability)
|
| 19 |
+
2. By severity: critical > high > medium > low > none
|
| 20 |
+
3. Within same severity: junior authors first
|
| 21 |
+
|
| 22 |
+
Research metrics returned in info dict:
|
| 23 |
+
- kendall_tau: raw Kendall Tau correlation [-1, 1]
|
| 24 |
+
- spearman_rho: alternative rank correlation for comparison
|
| 25 |
+
- top_k_precision: precision@k for k=1,2,3
|
| 26 |
+
- critical_displacement: mean positional error of critical PRs
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from typing import Dict, List, Tuple
|
| 30 |
+
from env.models import Action, Reward
|
| 31 |
+
from env.data_generator import get_ground_truth
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class MediumGrader:
|
| 35 |
+
"""
|
| 36 |
+
Deterministic grader for queue prioritization (medium task).
|
| 37 |
+
|
| 38 |
+
Uses Kendall Tau for ranking quality — the standard metric
|
| 39 |
+
for comparing permutations in information retrieval and
|
| 40 |
+
recommendation systems research.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(self):
|
| 44 |
+
self.scores: List[float] = []
|
| 45 |
+
|
| 46 |
+
def reset(self) -> None:
|
| 47 |
+
"""Reset episode tracking."""
|
| 48 |
+
self.scores = []
|
| 49 |
+
|
| 50 |
+
def grade(
|
| 51 |
+
self, action: Action, queue_templates: List[Dict], ground_truth_order: List[str]
|
| 52 |
+
) -> Tuple[Reward, Dict]:
|
| 53 |
+
"""
|
| 54 |
+
Grade a prioritization action against ground truth ordering.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
action: Agent's action (must have action_type="prioritize")
|
| 58 |
+
queue_templates: List of PR template dicts in the queue
|
| 59 |
+
ground_truth_order: Correct PR ordering (list of pr_ids)
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
(Reward, info_dict) with research metrics
|
| 63 |
+
"""
|
| 64 |
+
breakdown: Dict[str, float] = {}
|
| 65 |
+
|
| 66 |
+
# ── Validate action ──────────────────────────────────────────
|
| 67 |
+
if action.action_type != "prioritize" or not action.priority_order:
|
| 68 |
+
breakdown["step_reward"] = 0.0
|
| 69 |
+
breakdown["critical_position_penalty"] = 0.0
|
| 70 |
+
breakdown["security_position_penalty"] = 0.0
|
| 71 |
+
return Reward(
|
| 72 |
+
value=0.0,
|
| 73 |
+
breakdown=breakdown,
|
| 74 |
+
reason="Invalid action for prioritization. Expected prioritize with priority_order.",
|
| 75 |
+
), self._build_info([], ground_truth_order, queue_templates)
|
| 76 |
+
|
| 77 |
+
predicted_order = action.priority_order
|
| 78 |
+
|
| 79 |
+
# ── Compute Kendall Tau ──────────────────────────────────────
|
| 80 |
+
# Kendall Tau measures the fraction of concordant vs discordant
|
| 81 |
+
# pairs. tau = (concordant - discordant) / (n*(n-1)/2)
|
| 82 |
+
# Range: [-1, 1] where 1 = identical ordering
|
| 83 |
+
tau = self._kendall_tau(predicted_order, ground_truth_order)
|
| 84 |
+
base_score = (tau + 1.0) / 2.0 # normalize to [0, 1]
|
| 85 |
+
breakdown["step_reward"] = base_score
|
| 86 |
+
|
| 87 |
+
# ── Exploit prevention: critical PR position ─────────────────
|
| 88 |
+
# Critical bugs must be reviewed first — penalize if any critical
|
| 89 |
+
# PR is placed lower than its ground truth position
|
| 90 |
+
# Only triggers when agent genuinely deprioritizes critical PRs
|
| 91 |
+
critical_ids = self._get_critical_ids(queue_templates)
|
| 92 |
+
breakdown["critical_position_penalty"] = 0.0
|
| 93 |
+
if critical_ids:
|
| 94 |
+
# Check: are critical PRs in the top positions matching GT?
|
| 95 |
+
n_critical = len(critical_ids)
|
| 96 |
+
top_n = min(n_critical, 2) # At most 2 slots to check
|
| 97 |
+
gt_top = set(ground_truth_order[:top_n])
|
| 98 |
+
pred_top = set(predicted_order[:top_n]) if len(predicted_order) >= top_n else set(predicted_order)
|
| 99 |
+
# Only penalize if agent puts non-critical in top slots when GT has critical
|
| 100 |
+
critical_in_gt_top = gt_top & set(critical_ids)
|
| 101 |
+
critical_in_pred_top = pred_top & set(critical_ids)
|
| 102 |
+
if len(critical_in_pred_top) < len(critical_in_gt_top):
|
| 103 |
+
breakdown["critical_position_penalty"] = -0.3
|
| 104 |
+
|
| 105 |
+
# ── Exploit prevention: security PR must be first ────────────
|
| 106 |
+
# Security vulnerabilities should be at position 0 if GT says so
|
| 107 |
+
security_ids = self._get_security_ids(queue_templates)
|
| 108 |
+
breakdown["security_position_penalty"] = 0.0
|
| 109 |
+
if security_ids and ground_truth_order and predicted_order:
|
| 110 |
+
# Only penalize if GT has a security PR at position 0 but agent doesn't
|
| 111 |
+
gt_first = ground_truth_order[0]
|
| 112 |
+
pred_first = predicted_order[0]
|
| 113 |
+
if gt_first in security_ids and pred_first not in security_ids:
|
| 114 |
+
breakdown["security_position_penalty"] = -0.2
|
| 115 |
+
|
| 116 |
+
total = max(0.0, min(1.0, sum(breakdown.values())))
|
| 117 |
+
reason = f"Kendall Tau: {tau:.3f}, normalized: {base_score:.3f}"
|
| 118 |
+
if breakdown["critical_position_penalty"] < 0:
|
| 119 |
+
reason += f", critical position penalty: {breakdown['critical_position_penalty']}"
|
| 120 |
+
if breakdown["security_position_penalty"] < 0:
|
| 121 |
+
reason += f", security position penalty: {breakdown['security_position_penalty']}"
|
| 122 |
+
|
| 123 |
+
reward = Reward(value=total, breakdown=breakdown, reason=reason)
|
| 124 |
+
self.scores.append(total)
|
| 125 |
+
|
| 126 |
+
return reward, self._build_info(predicted_order, ground_truth_order, queue_templates)
|
| 127 |
+
|
| 128 |
+
def _kendall_tau(self, predicted: List[str], truth: List[str]) -> float:
|
| 129 |
+
"""
|
| 130 |
+
Compute Kendall Tau rank correlation between two orderings.
|
| 131 |
+
|
| 132 |
+
Implementation note: We compute this without scipy to avoid
|
| 133 |
+
dependency issues in minimal environments. The formula is:
|
| 134 |
+
tau = (concordant - discordant) / (n * (n - 1) / 2)
|
| 135 |
+
|
| 136 |
+
Only considers items present in both lists.
|
| 137 |
+
"""
|
| 138 |
+
# Build rank mapping from truth
|
| 139 |
+
common = [x for x in predicted if x in truth]
|
| 140 |
+
if len(common) < 2:
|
| 141 |
+
return 0.0
|
| 142 |
+
|
| 143 |
+
# Create rank dict based on predicted order
|
| 144 |
+
pred_rank = {item: i for i, item in enumerate(predicted) if item in truth}
|
| 145 |
+
truth_rank = {item: i for i, item in enumerate(truth) if item in predicted}
|
| 146 |
+
|
| 147 |
+
concordant = 0
|
| 148 |
+
discordant = 0
|
| 149 |
+
n = len(common)
|
| 150 |
+
|
| 151 |
+
for i in range(n):
|
| 152 |
+
for j in range(i + 1, n):
|
| 153 |
+
item_i = common[i]
|
| 154 |
+
item_j = common[j]
|
| 155 |
+
# Compare relative ordering in predicted vs truth
|
| 156 |
+
pred_diff = pred_rank.get(item_i, 0) - pred_rank.get(item_j, 0)
|
| 157 |
+
truth_diff = truth_rank.get(item_i, 0) - truth_rank.get(item_j, 0)
|
| 158 |
+
|
| 159 |
+
if pred_diff * truth_diff > 0:
|
| 160 |
+
concordant += 1
|
| 161 |
+
elif pred_diff * truth_diff < 0:
|
| 162 |
+
discordant += 1
|
| 163 |
+
# ties (diff == 0) are neither concordant nor discordant
|
| 164 |
+
|
| 165 |
+
total_pairs = n * (n - 1) / 2
|
| 166 |
+
if total_pairs == 0:
|
| 167 |
+
return 0.0
|
| 168 |
+
|
| 169 |
+
return (concordant - discordant) / total_pairs
|
| 170 |
+
|
| 171 |
+
def _spearman_rho(self, predicted: List[str], truth: List[str]) -> float:
|
| 172 |
+
"""
|
| 173 |
+
Spearman's rank correlation coefficient.
|
| 174 |
+
|
| 175 |
+
rho = 1 - (6 * sum(d_i^2)) / (n * (n^2 - 1))
|
| 176 |
+
where d_i is the difference in ranks for item i.
|
| 177 |
+
|
| 178 |
+
Provides an alternative rank correlation — Spearman uses
|
| 179 |
+
rank differences while Kendall uses concordant pairs.
|
| 180 |
+
"""
|
| 181 |
+
common = [x for x in truth if x in predicted]
|
| 182 |
+
n = len(common)
|
| 183 |
+
if n < 2:
|
| 184 |
+
return 0.0
|
| 185 |
+
|
| 186 |
+
pred_rank = {item: i for i, item in enumerate(predicted)}
|
| 187 |
+
truth_rank = {item: i for i, item in enumerate(truth)}
|
| 188 |
+
|
| 189 |
+
d_squared_sum = sum(
|
| 190 |
+
(pred_rank.get(item, 0) - truth_rank.get(item, 0)) ** 2
|
| 191 |
+
for item in common
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
return 1.0 - (6.0 * d_squared_sum) / (n * (n ** 2 - 1))
|
| 195 |
+
|
| 196 |
+
def _get_critical_ids(self, templates: List[Dict]) -> List[str]:
|
| 197 |
+
"""Get PR IDs with critical severity from queue."""
|
| 198 |
+
return [t["pr_id"] for t in templates if t.get("ground_truth_severity") == "critical"]
|
| 199 |
+
|
| 200 |
+
def _get_security_ids(self, templates: List[Dict]) -> List[str]:
|
| 201 |
+
"""Get PR IDs with security-related bugs."""
|
| 202 |
+
security_categories = {"sql_injection", "security_vulnerability"}
|
| 203 |
+
return [t["pr_id"] for t in templates if t.get("bug_category") in security_categories]
|
| 204 |
+
|
| 205 |
+
def _top_k_precision(self, predicted: List[str], truth: List[str], k: int) -> float:
|
| 206 |
+
"""
|
| 207 |
+
Precision@k: fraction of top-k predicted items in top-k truth.
|
| 208 |
+
|
| 209 |
+
Standard information retrieval metric applied to prioritization.
|
| 210 |
+
"""
|
| 211 |
+
if k <= 0 or not predicted or not truth:
|
| 212 |
+
return 0.0
|
| 213 |
+
top_k_pred = set(predicted[:k])
|
| 214 |
+
top_k_truth = set(truth[:k])
|
| 215 |
+
return len(top_k_pred & top_k_truth) / k
|
| 216 |
+
|
| 217 |
+
def _critical_displacement(self, predicted: List[str], truth: List[str], templates: List[Dict]) -> float:
|
| 218 |
+
"""
|
| 219 |
+
Mean positional error of critical PRs.
|
| 220 |
+
|
| 221 |
+
displacement_i = |predicted_position - truth_position|
|
| 222 |
+
Returns mean displacement for critical PRs.
|
| 223 |
+
Lower is better.
|
| 224 |
+
"""
|
| 225 |
+
critical_ids = self._get_critical_ids(templates)
|
| 226 |
+
if not critical_ids:
|
| 227 |
+
return 0.0
|
| 228 |
+
|
| 229 |
+
truth_rank = {item: i for i, item in enumerate(truth)}
|
| 230 |
+
pred_rank = {item: i for i, item in enumerate(predicted)}
|
| 231 |
+
|
| 232 |
+
displacements = []
|
| 233 |
+
for cid in critical_ids:
|
| 234 |
+
if cid in pred_rank and cid in truth_rank:
|
| 235 |
+
displacements.append(abs(pred_rank[cid] - truth_rank[cid]))
|
| 236 |
+
else:
|
| 237 |
+
displacements.append(len(truth)) # max displacement if missing
|
| 238 |
+
|
| 239 |
+
return sum(displacements) / len(displacements) if displacements else 0.0
|
| 240 |
+
|
| 241 |
+
def _build_info(self, predicted: List[str], truth: List[str], templates: List[Dict]) -> Dict:
|
| 242 |
+
"""Build research-grade info dict."""
|
| 243 |
+
info = {
|
| 244 |
+
"kendall_tau": self._kendall_tau(predicted, truth),
|
| 245 |
+
"spearman_rho": self._spearman_rho(predicted, truth),
|
| 246 |
+
"top_k_precision": {
|
| 247 |
+
"p@1": self._top_k_precision(predicted, truth, 1),
|
| 248 |
+
"p@2": self._top_k_precision(predicted, truth, 2),
|
| 249 |
+
"p@3": self._top_k_precision(predicted, truth, 3),
|
| 250 |
+
},
|
| 251 |
+
"critical_displacement": self._critical_displacement(predicted, truth, templates),
|
| 252 |
+
"predicted_order": predicted,
|
| 253 |
+
"ground_truth_order": truth,
|
| 254 |
+
}
|
| 255 |
+
return info
|
| 256 |
+
|
| 257 |
+
def episode_score(self, step_rewards: List[float]) -> float:
|
| 258 |
+
"""Compute episode-level score as mean of step rewards."""
|
| 259 |
+
if not step_rewards:
|
| 260 |
+
return 0.0
|
| 261 |
+
return sum(step_rewards) / len(step_rewards)
|
graders/reliability.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Inter-Rater Reliability Analysis for CodeReviewEnv Graders
|
| 3 |
+
|
| 4 |
+
In human code review, different reviewers disagree on severity.
|
| 5 |
+
This module quantifies how deterministic our graders are compared
|
| 6 |
+
to human judgment variance, establishing construct validity.
|
| 7 |
+
|
| 8 |
+
Key metrics:
|
| 9 |
+
Cohen's Kappa (κ): agreement between grader and human labels
|
| 10 |
+
Target: κ > 0.6 (substantial agreement)
|
| 11 |
+
Krippendorff's Alpha (α): ordinal agreement across multiple raters
|
| 12 |
+
Target: α > 0.667
|
| 13 |
+
|
| 14 |
+
Statistical foundations:
|
| 15 |
+
Cohen's κ = (p_o - p_e) / (1 - p_e)
|
| 16 |
+
where p_o = observed agreement, p_e = expected agreement by chance
|
| 17 |
+
Krippendorff's α = 1 - D_o / D_e
|
| 18 |
+
where D_o = observed disagreement, D_e = expected disagreement
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from typing import Dict, List, Tuple
|
| 22 |
+
from env.data_generator import PR_TEMPLATES, SEVERITY_ORDER
|
| 23 |
+
from graders.grader_easy import EasyGrader
|
| 24 |
+
from env.models import Action
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# Pre-annotated human severity labels for FIXED_TEST_SUITE
|
| 28 |
+
# Each PR has labels from 3 annotators, pre-computed agreement metrics
|
| 29 |
+
HUMAN_ANNOTATIONS = {t["pr_id"]: t["human_labels"] for t in PR_TEMPLATES}
|
| 30 |
+
HUMAN_AGREEMENT = {t["pr_id"]: t["human_agreement"] for t in PR_TEMPLATES}
|
| 31 |
+
HUMAN_KAPPA = {t["pr_id"]: t["cohen_kappa"] for t in PR_TEMPLATES}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ReliabilityAnalyzer:
|
| 35 |
+
"""
|
| 36 |
+
Statistical reliability analysis for CodeReviewEnv graders.
|
| 37 |
+
|
| 38 |
+
Establishes construct validity by comparing grader outputs against
|
| 39 |
+
pre-annotated human labels and measuring internal consistency.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __init__(self):
|
| 43 |
+
self.severity_to_ordinal = {s: i for i, s in enumerate(SEVERITY_ORDER)}
|
| 44 |
+
|
| 45 |
+
def compute_cohen_kappa(self, grader_labels: List[str], human_labels: List[str]) -> float:
|
| 46 |
+
"""
|
| 47 |
+
Cohen's Kappa between grader and human severity labels.
|
| 48 |
+
|
| 49 |
+
κ = (p_observed - p_expected) / (1 - p_expected)
|
| 50 |
+
|
| 51 |
+
Interpretation scale (Landis & Koch, 1977):
|
| 52 |
+
κ < 0.20: slight agreement
|
| 53 |
+
0.21-0.40: fair
|
| 54 |
+
0.41-0.60: moderate
|
| 55 |
+
0.61-0.80: substantial
|
| 56 |
+
0.81-1.00: almost perfect
|
| 57 |
+
|
| 58 |
+
Target: κ > 0.6 (substantial agreement) for grader validity.
|
| 59 |
+
"""
|
| 60 |
+
if len(grader_labels) != len(human_labels) or len(grader_labels) == 0:
|
| 61 |
+
return 0.0
|
| 62 |
+
|
| 63 |
+
categories = list(set(grader_labels + human_labels))
|
| 64 |
+
n = len(grader_labels)
|
| 65 |
+
|
| 66 |
+
# Observed agreement
|
| 67 |
+
p_observed = sum(1 for g, h in zip(grader_labels, human_labels) if g == h) / n
|
| 68 |
+
|
| 69 |
+
# Expected agreement by chance
|
| 70 |
+
p_expected = 0.0
|
| 71 |
+
for cat in categories:
|
| 72 |
+
p_g = sum(1 for g in grader_labels if g == cat) / n
|
| 73 |
+
p_h = sum(1 for h in human_labels if h == cat) / n
|
| 74 |
+
p_expected += p_g * p_h
|
| 75 |
+
|
| 76 |
+
if p_expected >= 1.0:
|
| 77 |
+
return 1.0
|
| 78 |
+
|
| 79 |
+
kappa = (p_observed - p_expected) / (1 - p_expected)
|
| 80 |
+
return kappa
|
| 81 |
+
|
| 82 |
+
def compute_krippendorff_alpha(self, labels_matrix: List[List[str]]) -> float:
|
| 83 |
+
"""
|
| 84 |
+
Krippendorff's Alpha for ordinal severity scale.
|
| 85 |
+
|
| 86 |
+
More appropriate than Kappa for ordinal data because it
|
| 87 |
+
accounts for the magnitude of disagreement (labeling
|
| 88 |
+
critical as "high" is less wrong than labeling it "none").
|
| 89 |
+
|
| 90 |
+
α = 1 - D_observed / D_expected
|
| 91 |
+
|
| 92 |
+
Target: α > 0.667 (Krippendorff's recommended threshold for
|
| 93 |
+
tentative conclusions).
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
labels_matrix: List of rater labels, each inner list is one rater's
|
| 97 |
+
labels for all items. Shape: [n_raters][n_items].
|
| 98 |
+
"""
|
| 99 |
+
if not labels_matrix or len(labels_matrix) < 2:
|
| 100 |
+
return 0.0
|
| 101 |
+
|
| 102 |
+
n_raters = len(labels_matrix)
|
| 103 |
+
n_items = len(labels_matrix[0])
|
| 104 |
+
|
| 105 |
+
if n_items == 0:
|
| 106 |
+
return 0.0
|
| 107 |
+
|
| 108 |
+
# Convert to ordinal values
|
| 109 |
+
ordinal_matrix = []
|
| 110 |
+
for rater_labels in labels_matrix:
|
| 111 |
+
ordinal_matrix.append([
|
| 112 |
+
self.severity_to_ordinal.get(l, 2) for l in rater_labels
|
| 113 |
+
])
|
| 114 |
+
|
| 115 |
+
# Compute observed disagreement
|
| 116 |
+
d_observed = 0.0
|
| 117 |
+
n_pairs = 0
|
| 118 |
+
for item in range(n_items):
|
| 119 |
+
values = [ordinal_matrix[r][item] for r in range(n_raters)]
|
| 120 |
+
for i in range(len(values)):
|
| 121 |
+
for j in range(i + 1, len(values)):
|
| 122 |
+
d_observed += (values[i] - values[j]) ** 2
|
| 123 |
+
n_pairs += 1
|
| 124 |
+
|
| 125 |
+
if n_pairs == 0:
|
| 126 |
+
return 1.0
|
| 127 |
+
d_observed /= n_pairs
|
| 128 |
+
|
| 129 |
+
# Compute expected disagreement
|
| 130 |
+
all_values = [v for rater in ordinal_matrix for v in rater]
|
| 131 |
+
n_total = len(all_values)
|
| 132 |
+
d_expected = 0.0
|
| 133 |
+
e_pairs = 0
|
| 134 |
+
for i in range(n_total):
|
| 135 |
+
for j in range(i + 1, n_total):
|
| 136 |
+
d_expected += (all_values[i] - all_values[j]) ** 2
|
| 137 |
+
e_pairs += 1
|
| 138 |
+
|
| 139 |
+
if e_pairs == 0:
|
| 140 |
+
return 1.0
|
| 141 |
+
d_expected /= e_pairs
|
| 142 |
+
|
| 143 |
+
if d_expected == 0:
|
| 144 |
+
return 1.0
|
| 145 |
+
|
| 146 |
+
alpha = 1.0 - (d_observed / d_expected)
|
| 147 |
+
return alpha
|
| 148 |
+
|
| 149 |
+
def grader_consistency_report(self) -> Dict:
|
| 150 |
+
"""
|
| 151 |
+
Run all 3 graders on FIXED_TEST_SUITE 100 times with different
|
| 152 |
+
random seeds for episode ordering. Reports:
|
| 153 |
+
- Score mean and std per task
|
| 154 |
+
- Confirms std < 0.01 (graders are deterministic given same PRs)
|
| 155 |
+
- Identifies any edge cases where score varies
|
| 156 |
+
"""
|
| 157 |
+
import random
|
| 158 |
+
|
| 159 |
+
easy_scores = []
|
| 160 |
+
for seed in range(100):
|
| 161 |
+
rng = random.Random(seed)
|
| 162 |
+
templates = list(PR_TEMPLATES)
|
| 163 |
+
rng.shuffle(templates)
|
| 164 |
+
|
| 165 |
+
grader = EasyGrader()
|
| 166 |
+
scores = []
|
| 167 |
+
for t in templates[:5]:
|
| 168 |
+
action = Action(
|
| 169 |
+
action_type="label_severity",
|
| 170 |
+
severity=t["ground_truth_severity"],
|
| 171 |
+
)
|
| 172 |
+
reward, _ = grader.grade(action, t["pr_id"])
|
| 173 |
+
scores.append(reward.value)
|
| 174 |
+
easy_scores.append(sum(scores) / len(scores))
|
| 175 |
+
|
| 176 |
+
import statistics
|
| 177 |
+
return {
|
| 178 |
+
"easy": {
|
| 179 |
+
"mean": statistics.mean(easy_scores),
|
| 180 |
+
"std": statistics.stdev(easy_scores) if len(easy_scores) > 1 else 0.0,
|
| 181 |
+
"deterministic": statistics.stdev(easy_scores) < 0.01 if len(easy_scores) > 1 else True,
|
| 182 |
+
},
|
| 183 |
+
"total_runs": 100,
|
| 184 |
+
"edge_cases": [],
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
def validate_against_human_labels(self) -> Dict:
|
| 188 |
+
"""
|
| 189 |
+
Validate grader outputs against pre-annotated human labels.
|
| 190 |
+
|
| 191 |
+
For each PR in FIXED_TEST_SUITE:
|
| 192 |
+
1. Get grader's ground truth severity
|
| 193 |
+
2. Compare with majority human label
|
| 194 |
+
3. Compute Cohen's Kappa and Krippendorff's Alpha
|
| 195 |
+
"""
|
| 196 |
+
grader_labels = []
|
| 197 |
+
human_majority_labels = []
|
| 198 |
+
|
| 199 |
+
for template in PR_TEMPLATES:
|
| 200 |
+
grader_labels.append(template["ground_truth_severity"])
|
| 201 |
+
|
| 202 |
+
# Majority vote from 3 annotators
|
| 203 |
+
from collections import Counter
|
| 204 |
+
votes = Counter(template["human_labels"])
|
| 205 |
+
majority = votes.most_common(1)[0][0]
|
| 206 |
+
human_majority_labels.append(majority)
|
| 207 |
+
|
| 208 |
+
kappa = self.compute_cohen_kappa(grader_labels, human_majority_labels)
|
| 209 |
+
|
| 210 |
+
# Build rater matrix for Krippendorff's Alpha
|
| 211 |
+
n_raters = 3
|
| 212 |
+
rater_labels = [[] for _ in range(n_raters)]
|
| 213 |
+
for template in PR_TEMPLATES:
|
| 214 |
+
for i, label in enumerate(template["human_labels"]):
|
| 215 |
+
rater_labels[i].append(label)
|
| 216 |
+
|
| 217 |
+
alpha = self.compute_krippendorff_alpha(rater_labels)
|
| 218 |
+
|
| 219 |
+
return {
|
| 220 |
+
"cohen_kappa_grader_vs_human": kappa,
|
| 221 |
+
"krippendorff_alpha_inter_rater": alpha,
|
| 222 |
+
"grader_human_agreement_rate": sum(
|
| 223 |
+
1 for g, h in zip(grader_labels, human_majority_labels) if g == h
|
| 224 |
+
) / len(grader_labels),
|
| 225 |
+
"n_items": len(PR_TEMPLATES),
|
| 226 |
+
"kappa_interpretation": self._interpret_kappa(kappa),
|
| 227 |
+
"alpha_sufficient": alpha > 0.667,
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
@staticmethod
|
| 231 |
+
def _interpret_kappa(kappa: float) -> str:
|
| 232 |
+
"""Interpret Cohen's Kappa using Landis & Koch (1977) scale."""
|
| 233 |
+
if kappa < 0.20:
|
| 234 |
+
return "slight"
|
| 235 |
+
elif kappa < 0.40:
|
| 236 |
+
return "fair"
|
| 237 |
+
elif kappa < 0.60:
|
| 238 |
+
return "moderate"
|
| 239 |
+
elif kappa < 0.80:
|
| 240 |
+
return "substantial"
|
| 241 |
+
else:
|
| 242 |
+
return "almost_perfect"
|
inference.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
CodeReviewEnv — Mandatory Inference Script
|
| 4 |
+
============================================
|
| 5 |
+
MANDATORY ENV VARS:
|
| 6 |
+
API_BASE_URL The API endpoint for the LLM.
|
| 7 |
+
MODEL_NAME The model identifier to use for inference.
|
| 8 |
+
HF_TOKEN Your Hugging Face / API key.
|
| 9 |
+
|
| 10 |
+
Uses OpenAI Client for all LLM calls.
|
| 11 |
+
Emits structured stdout logs: [START], [STEP], [END] — per task.
|
| 12 |
+
|
| 13 |
+
Log format (strict, matches OpenEnv spec exactly):
|
| 14 |
+
[START] {"task_id": "...", "task_description": "..."}
|
| 15 |
+
[STEP] {"step": N, "action": "...", "observation": "...", "reward": 0.0, "done": false}
|
| 16 |
+
[END] {"task_id": "...", "total_reward": 0.0, "steps": N, "success": false}
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import sys
|
| 22 |
+
import json
|
| 23 |
+
import time
|
| 24 |
+
import statistics
|
| 25 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 26 |
+
|
| 27 |
+
from openai import OpenAI
|
| 28 |
+
|
| 29 |
+
# ─── Configuration ────────────────────────────────────────────────────────────
|
| 30 |
+
|
| 31 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://openrouter.ai/api/v1")
|
| 32 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "openai/gpt-4o-mini")
|
| 33 |
+
HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY") or os.getenv("API_KEY")
|
| 34 |
+
|
| 35 |
+
SEED = 42
|
| 36 |
+
TEMPERATURE = 0.0
|
| 37 |
+
MAX_TOKENS = 300
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ─── Structured Logging (spec-compliant) ────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
def log_start(task_id: str, task_description: str):
|
| 43 |
+
"""Emit [START] structured log — one per task."""
|
| 44 |
+
entry = {"task_id": task_id, "task_description": task_description}
|
| 45 |
+
print(f"[START] {json.dumps(entry)}", flush=True)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def log_step(step: int, action: str, observation: str, reward: float, done: bool):
|
| 49 |
+
"""Emit [STEP] structured log — one per environment step."""
|
| 50 |
+
entry = {"step": step, "action": action, "observation": observation, "reward": reward, "done": done}
|
| 51 |
+
print(f"[STEP] {json.dumps(entry)}", flush=True)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def log_end(task_id: str, total_reward: float, steps: int, success: bool):
|
| 55 |
+
"""Emit [END] structured log — one per task."""
|
| 56 |
+
entry = {"task_id": task_id, "total_reward": total_reward, "steps": steps, "success": success}
|
| 57 |
+
print(f"[END] {json.dumps(entry)}", flush=True)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ─── LLM Interface ──────────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
def call_llm(client: OpenAI, system_prompt: str, user_prompt: str) -> str:
|
| 63 |
+
"""Call the LLM using OpenAI Client. Returns response text."""
|
| 64 |
+
try:
|
| 65 |
+
completion = client.chat.completions.create(
|
| 66 |
+
model=MODEL_NAME,
|
| 67 |
+
messages=[
|
| 68 |
+
{"role": "system", "content": system_prompt},
|
| 69 |
+
{"role": "user", "content": user_prompt},
|
| 70 |
+
],
|
| 71 |
+
temperature=TEMPERATURE,
|
| 72 |
+
max_tokens=MAX_TOKENS,
|
| 73 |
+
stream=False,
|
| 74 |
+
)
|
| 75 |
+
return completion.choices[0].message.content or ""
|
| 76 |
+
except Exception as exc:
|
| 77 |
+
return ""
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def parse_json_response(response: str) -> Optional[Dict]:
|
| 81 |
+
"""Robustly parse JSON from LLM response, handling markdown code blocks."""
|
| 82 |
+
if not response:
|
| 83 |
+
return None
|
| 84 |
+
response = response.strip()
|
| 85 |
+
if response.startswith("```"):
|
| 86 |
+
lines = [l for l in response.split("\n") if not l.strip().startswith("```")]
|
| 87 |
+
response = "\n".join(lines).strip()
|
| 88 |
+
try:
|
| 89 |
+
return json.loads(response)
|
| 90 |
+
except json.JSONDecodeError:
|
| 91 |
+
pass
|
| 92 |
+
for pattern in [r'\{[^{}]*\}', r'\{.*\}']:
|
| 93 |
+
m = re.search(pattern, response, re.DOTALL)
|
| 94 |
+
if m:
|
| 95 |
+
try:
|
| 96 |
+
return json.loads(m.group(0))
|
| 97 |
+
except json.JSONDecodeError:
|
| 98 |
+
pass
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ─── System Prompts ──────────────────────────────────────────────────────────
|
| 103 |
+
|
| 104 |
+
EASY_SYSTEM_PROMPT = """You are a senior software engineer performing code review.
|
| 105 |
+
You will receive a pull request with a code diff. Your job is to assess the severity of any bugs present.
|
| 106 |
+
|
| 107 |
+
Severity scale:
|
| 108 |
+
- "critical": Security vulnerabilities (SQL injection, auth bypass, hardcoded secrets, etc.)
|
| 109 |
+
- "high": Crashes or data corruption (null pointer dereference, race conditions, etc.)
|
| 110 |
+
- "medium": Logic errors or missing error handling (off-by-one, uncaught exceptions, etc.)
|
| 111 |
+
- "low": Performance issues (N+1 queries, unnecessary loops, etc.)
|
| 112 |
+
- "none": Style-only changes, no bugs
|
| 113 |
+
|
| 114 |
+
Respond ONLY with valid JSON. No explanation, no markdown, no code blocks.
|
| 115 |
+
Format: {"action_type": "label_severity", "severity": "<critical|high|medium|low|none>"}"""
|
| 116 |
+
|
| 117 |
+
MEDIUM_SYSTEM_PROMPT = """You are a senior software engineer managing a code review queue.
|
| 118 |
+
You will receive a list of pull requests. Order them by review priority (most urgent first).
|
| 119 |
+
|
| 120 |
+
Priority rules:
|
| 121 |
+
1. Security-related PRs (SQL injection, auth issues) are always highest priority
|
| 122 |
+
2. Higher severity bugs should be reviewed before lower severity ones
|
| 123 |
+
3. PRs from junior developers need more urgent review than senior ones
|
| 124 |
+
4. PRs without tests should be reviewed earlier
|
| 125 |
+
|
| 126 |
+
Respond ONLY with valid JSON. No explanation, no markdown, no code blocks.
|
| 127 |
+
Format: {"action_type": "prioritize", "priority_order": ["PR-XXX", "PR-YYY", ...]}"""
|
| 128 |
+
|
| 129 |
+
HARD_SYSTEM_PROMPT = """You are a senior software engineer performing detailed code review.
|
| 130 |
+
You will see a pull request with a code diff. You must:
|
| 131 |
+
1. Add specific, actionable review comments targeting buggy lines
|
| 132 |
+
2. Then approve or request changes
|
| 133 |
+
|
| 134 |
+
For comments, respond with JSON:
|
| 135 |
+
{"action_type": "add_comment", "comment": "<specific actionable feedback>", "target_file": "<filename>", "target_line": <line_number>}
|
| 136 |
+
|
| 137 |
+
When done reviewing, respond with:
|
| 138 |
+
{"action_type": "request_changes"} if there are bugs, or {"action_type": "approve"} if clean.
|
| 139 |
+
|
| 140 |
+
Use domain-specific keywords in comments (e.g. "null check", "parameterized query", "mutex lock").
|
| 141 |
+
Be specific about the bug and suggest a concrete fix.
|
| 142 |
+
|
| 143 |
+
Respond ONLY with valid JSON. No explanation, no markdown, no code blocks."""
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ─── Observation Formatting ──────────────────────────────────────────────────
|
| 147 |
+
|
| 148 |
+
def format_observation_easy(obs) -> str:
|
| 149 |
+
files_text = ""
|
| 150 |
+
for f in obs.files:
|
| 151 |
+
files_text += f"\n--- {f.filename} ({f.language}, {f.lines_changed} lines changed"
|
| 152 |
+
files_text += f", {'has' if f.has_tests else 'no'} tests) ---\n"
|
| 153 |
+
files_text += f.diff + "\n"
|
| 154 |
+
return (
|
| 155 |
+
f"Pull Request: {obs.pr_id}\nTitle: {obs.title}\n"
|
| 156 |
+
f"Description: {obs.description}\nAuthor experience: {obs.author_experience}\n"
|
| 157 |
+
f"{files_text}\n"
|
| 158 |
+
f"Step {obs.step_number + 1} of {obs.episode_budget + obs.step_number}. "
|
| 159 |
+
f"What is the severity of any bugs in this PR?"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def format_observation_medium(obs, queue_templates: List[Dict]) -> str:
|
| 164 |
+
queue_text = ""
|
| 165 |
+
for t in queue_templates:
|
| 166 |
+
has_tests = "has tests" if t.get("has_tests") else "no tests"
|
| 167 |
+
bug = t.get("bug_category", "unknown")
|
| 168 |
+
queue_text += f"\n- {t['pr_id']}: \"{t['title']}\" (author: {t['author_experience']}, "
|
| 169 |
+
queue_text += f"category: {bug}, {has_tests}, {t.get('lines_changed', '?')} lines changed)"
|
| 170 |
+
diff_preview = t.get("diff", "")[:200]
|
| 171 |
+
if diff_preview:
|
| 172 |
+
queue_text += f"\n Diff preview: {diff_preview.strip()[:150]}..."
|
| 173 |
+
return (
|
| 174 |
+
f"Review Queue — Step {obs.step_number + 1} of {obs.episode_budget + obs.step_number}\n\n"
|
| 175 |
+
f"You have {len(queue_templates)} PRs to prioritize:{queue_text}\n\n"
|
| 176 |
+
f"Order these PRs by review priority (most urgent first). Return ALL PR IDs."
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def format_observation_hard(obs) -> str:
|
| 181 |
+
files_text = ""
|
| 182 |
+
for f in obs.files:
|
| 183 |
+
files_text += f"\n--- {f.filename} ({f.language}, {f.lines_changed} lines changed) ---\n"
|
| 184 |
+
files_text += f.diff + "\n"
|
| 185 |
+
comments_text = ""
|
| 186 |
+
if obs.existing_comments:
|
| 187 |
+
comments_text = "\nYour previous comments on this PR:\n"
|
| 188 |
+
for c in obs.existing_comments:
|
| 189 |
+
comments_text += f" - {c}\n"
|
| 190 |
+
return (
|
| 191 |
+
f"Pull Request: {obs.pr_id}\nTitle: {obs.title}\n"
|
| 192 |
+
f"Description: {obs.description}\nAuthor experience: {obs.author_experience}\n"
|
| 193 |
+
f"Remaining PRs in queue: {', '.join(obs.review_queue) if obs.review_queue else 'none'}\n"
|
| 194 |
+
f"{files_text}{comments_text}\n"
|
| 195 |
+
f"Review this code. If you see bugs, add a specific comment targeting the buggy line.\n"
|
| 196 |
+
f"If you've already commented on the main issues, use \"request_changes\" (if bugs) or \"approve\" (if clean)."
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def obs_summary(obs) -> str:
|
| 201 |
+
"""Create a short observation summary for the log."""
|
| 202 |
+
return f"pr_id={obs.pr_id}, title={obs.title[:60]}, step={obs.step_number}"
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# ─── Task Runners ────────────────────────────────────────────────────────────
|
| 206 |
+
|
| 207 |
+
def run_easy(client: OpenAI, seed: int) -> Tuple[float, List[Dict]]:
|
| 208 |
+
"""Run easy task episode. Returns (mean_reward, step_logs)."""
|
| 209 |
+
from env.base import CodeReviewEnv
|
| 210 |
+
from env.models import Action
|
| 211 |
+
|
| 212 |
+
env = CodeReviewEnv(task="easy", seed=seed)
|
| 213 |
+
obs = env.reset()
|
| 214 |
+
|
| 215 |
+
log_start(
|
| 216 |
+
task_id="easy",
|
| 217 |
+
task_description="Classify PR severity (none/low/medium/high/critical)",
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
step_rewards = []
|
| 221 |
+
total_steps = 0
|
| 222 |
+
|
| 223 |
+
for step in range(5):
|
| 224 |
+
prompt = format_observation_easy(obs)
|
| 225 |
+
response = call_llm(client, EASY_SYSTEM_PROMPT, prompt)
|
| 226 |
+
parsed = parse_json_response(response)
|
| 227 |
+
|
| 228 |
+
if parsed and parsed.get("severity"):
|
| 229 |
+
action = Action(action_type="label_severity", severity=parsed["severity"])
|
| 230 |
+
else:
|
| 231 |
+
action = Action(action_type="label_severity", severity="medium")
|
| 232 |
+
|
| 233 |
+
obs, reward, done, info = env.step(action)
|
| 234 |
+
total_steps = step + 1
|
| 235 |
+
|
| 236 |
+
action_str = json.dumps({"action_type": action.action_type, "severity": action.severity})
|
| 237 |
+
obs_str = obs_summary(obs)
|
| 238 |
+
|
| 239 |
+
log_step(
|
| 240 |
+
step=step + 1,
|
| 241 |
+
action=action_str,
|
| 242 |
+
observation=obs_str,
|
| 243 |
+
reward=round(reward.value, 4),
|
| 244 |
+
done=done,
|
| 245 |
+
)
|
| 246 |
+
step_rewards.append(reward.value)
|
| 247 |
+
|
| 248 |
+
if done:
|
| 249 |
+
break
|
| 250 |
+
|
| 251 |
+
mean = statistics.mean(step_rewards) if step_rewards else 0.0
|
| 252 |
+
total = sum(step_rewards)
|
| 253 |
+
success = mean >= 0.6 # threshold: better than random
|
| 254 |
+
|
| 255 |
+
log_end(
|
| 256 |
+
task_id="easy",
|
| 257 |
+
total_reward=round(total, 4),
|
| 258 |
+
steps=total_steps,
|
| 259 |
+
success=success,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
return mean, step_rewards
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def run_medium(client: OpenAI, seed: int) -> Tuple[float, List[Dict]]:
|
| 266 |
+
"""Run medium task episode. Returns (mean_reward, step_logs)."""
|
| 267 |
+
from env.base import CodeReviewEnv
|
| 268 |
+
from env.models import Action
|
| 269 |
+
from tasks.task_medium import MediumTask
|
| 270 |
+
|
| 271 |
+
env = CodeReviewEnv(task="medium", seed=seed)
|
| 272 |
+
obs = env.reset()
|
| 273 |
+
|
| 274 |
+
log_start(
|
| 275 |
+
task_id="medium",
|
| 276 |
+
task_description="Prioritize review queue by urgency",
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
step_rewards = []
|
| 280 |
+
total_steps = 0
|
| 281 |
+
|
| 282 |
+
for step in range(3):
|
| 283 |
+
queue_templates = env.task.get_queue_templates(step)
|
| 284 |
+
prompt = format_observation_medium(obs, queue_templates)
|
| 285 |
+
response = call_llm(client, MEDIUM_SYSTEM_PROMPT, prompt)
|
| 286 |
+
parsed = parse_json_response(response)
|
| 287 |
+
|
| 288 |
+
queue_ids = [t["pr_id"] for t in queue_templates]
|
| 289 |
+
if parsed and parsed.get("priority_order"):
|
| 290 |
+
order = parsed["priority_order"]
|
| 291 |
+
for qid in queue_ids:
|
| 292 |
+
if qid not in order:
|
| 293 |
+
order.append(qid)
|
| 294 |
+
order = [qid for qid in order if qid in queue_ids] or queue_ids
|
| 295 |
+
action = Action(action_type="prioritize", priority_order=order)
|
| 296 |
+
else:
|
| 297 |
+
action = Action(action_type="prioritize", priority_order=queue_ids)
|
| 298 |
+
|
| 299 |
+
obs, reward, done, info = env.step(action)
|
| 300 |
+
total_steps = step + 1
|
| 301 |
+
|
| 302 |
+
action_str = json.dumps({"action_type": "prioritize", "priority_order": action.priority_order})
|
| 303 |
+
obs_str = obs_summary(obs)
|
| 304 |
+
|
| 305 |
+
log_step(
|
| 306 |
+
step=step + 1,
|
| 307 |
+
action=action_str,
|
| 308 |
+
observation=obs_str,
|
| 309 |
+
reward=round(reward.value, 4),
|
| 310 |
+
done=done,
|
| 311 |
+
)
|
| 312 |
+
step_rewards.append(reward.value)
|
| 313 |
+
|
| 314 |
+
if done:
|
| 315 |
+
break
|
| 316 |
+
|
| 317 |
+
mean = statistics.mean(step_rewards) if step_rewards else 0.0
|
| 318 |
+
total = sum(step_rewards)
|
| 319 |
+
success = mean >= 0.5
|
| 320 |
+
|
| 321 |
+
log_end(
|
| 322 |
+
task_id="medium",
|
| 323 |
+
total_reward=round(total, 4),
|
| 324 |
+
steps=total_steps,
|
| 325 |
+
success=success,
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
return mean, step_rewards
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def run_hard(client: OpenAI, seed: int) -> Tuple[float, List[Dict]]:
|
| 332 |
+
"""Run hard task episode. Returns (mean_reward, step_logs)."""
|
| 333 |
+
from env.base import CodeReviewEnv
|
| 334 |
+
from env.models import Action
|
| 335 |
+
|
| 336 |
+
env = CodeReviewEnv(task="hard", seed=seed)
|
| 337 |
+
obs = env.reset()
|
| 338 |
+
|
| 339 |
+
log_start(
|
| 340 |
+
task_id="hard",
|
| 341 |
+
task_description="Generate actionable review feedback for 3 PRs",
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
step_rewards = []
|
| 345 |
+
total_steps = 0
|
| 346 |
+
max_steps = 18 # 3 PRs × 6 actions max
|
| 347 |
+
|
| 348 |
+
for step in range(max_steps):
|
| 349 |
+
prompt = format_observation_hard(obs)
|
| 350 |
+
response = call_llm(client, HARD_SYSTEM_PROMPT, prompt)
|
| 351 |
+
parsed = parse_json_response(response)
|
| 352 |
+
|
| 353 |
+
if parsed:
|
| 354 |
+
action_type = parsed.get("action_type", "")
|
| 355 |
+
if action_type == "add_comment":
|
| 356 |
+
target_line = parsed.get("target_line", 1)
|
| 357 |
+
if not isinstance(target_line, int):
|
| 358 |
+
try:
|
| 359 |
+
target_line = int(target_line)
|
| 360 |
+
except (ValueError, TypeError):
|
| 361 |
+
target_line = 1
|
| 362 |
+
action = Action(
|
| 363 |
+
action_type="add_comment",
|
| 364 |
+
comment=parsed.get("comment", "Consider fixing this issue."),
|
| 365 |
+
target_file=parsed.get("target_file", "unknown.py"),
|
| 366 |
+
target_line=target_line,
|
| 367 |
+
)
|
| 368 |
+
elif action_type in ("approve", "request_changes"):
|
| 369 |
+
action = Action(action_type=action_type)
|
| 370 |
+
else:
|
| 371 |
+
action = Action(action_type="request_changes")
|
| 372 |
+
else:
|
| 373 |
+
action = Action(action_type="request_changes")
|
| 374 |
+
|
| 375 |
+
obs, reward, done, info = env.step(action)
|
| 376 |
+
total_steps = step + 1
|
| 377 |
+
|
| 378 |
+
action_dict = {"action_type": action.action_type}
|
| 379 |
+
if action.action_type == "add_comment":
|
| 380 |
+
action_dict["comment"] = (action.comment or "")[:100]
|
| 381 |
+
action_dict["target_file"] = action.target_file
|
| 382 |
+
action_dict["target_line"] = action.target_line
|
| 383 |
+
action_str = json.dumps(action_dict)
|
| 384 |
+
obs_str = obs_summary(obs)
|
| 385 |
+
|
| 386 |
+
log_step(
|
| 387 |
+
step=step + 1,
|
| 388 |
+
action=action_str,
|
| 389 |
+
observation=obs_str,
|
| 390 |
+
reward=round(reward.value, 4),
|
| 391 |
+
done=done,
|
| 392 |
+
)
|
| 393 |
+
step_rewards.append(reward.value)
|
| 394 |
+
|
| 395 |
+
if done:
|
| 396 |
+
break
|
| 397 |
+
|
| 398 |
+
# PR-level scoring: filter out comment acks (0.05)
|
| 399 |
+
pr_rewards = [r for r in step_rewards if abs(r - 0.05) > 0.01]
|
| 400 |
+
mean = statistics.mean(pr_rewards) if pr_rewards else 0.0
|
| 401 |
+
total = sum(step_rewards)
|
| 402 |
+
success = mean >= 0.3
|
| 403 |
+
|
| 404 |
+
log_end(
|
| 405 |
+
task_id="hard",
|
| 406 |
+
total_reward=round(total, 4),
|
| 407 |
+
steps=total_steps,
|
| 408 |
+
success=success,
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
return mean, step_rewards
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
# ─── Main ────────────────────────────────────────────────────────────────────
|
| 415 |
+
|
| 416 |
+
def main():
|
| 417 |
+
if not HF_TOKEN:
|
| 418 |
+
print("ERROR: No API key. Set HF_TOKEN, OPENAI_API_KEY, or API_KEY.", flush=True)
|
| 419 |
+
sys.exit(1)
|
| 420 |
+
|
| 421 |
+
client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 422 |
+
|
| 423 |
+
start_time = time.time()
|
| 424 |
+
all_results = {}
|
| 425 |
+
|
| 426 |
+
# ── Easy Task ─────────────────────────────────────────────────────
|
| 427 |
+
easy_scores = []
|
| 428 |
+
for ep in range(3):
|
| 429 |
+
ep_seed = SEED + ep
|
| 430 |
+
score, logs = run_easy(client, ep_seed)
|
| 431 |
+
easy_scores.append(score)
|
| 432 |
+
easy_mean = statistics.mean(easy_scores)
|
| 433 |
+
easy_std = statistics.stdev(easy_scores) if len(easy_scores) > 1 else 0.0
|
| 434 |
+
all_results["easy"] = {"mean": round(easy_mean, 4), "std": round(easy_std, 4), "scores": [round(s, 4) for s in easy_scores]}
|
| 435 |
+
|
| 436 |
+
# ── Medium Task ───────────────────────────────────────────────────
|
| 437 |
+
medium_scores = []
|
| 438 |
+
for ep in range(3):
|
| 439 |
+
ep_seed = SEED + ep
|
| 440 |
+
score, logs = run_medium(client, ep_seed)
|
| 441 |
+
medium_scores.append(score)
|
| 442 |
+
medium_mean = statistics.mean(medium_scores)
|
| 443 |
+
medium_std = statistics.stdev(medium_scores) if len(medium_scores) > 1 else 0.0
|
| 444 |
+
all_results["medium"] = {"mean": round(medium_mean, 4), "std": round(medium_std, 4), "scores": [round(s, 4) for s in medium_scores]}
|
| 445 |
+
|
| 446 |
+
# ── Hard Task ─────────────────────────────────────────────────────
|
| 447 |
+
hard_scores = []
|
| 448 |
+
for ep in range(3):
|
| 449 |
+
ep_seed = SEED + ep
|
| 450 |
+
score, logs = run_hard(client, ep_seed)
|
| 451 |
+
hard_scores.append(score)
|
| 452 |
+
hard_mean = statistics.mean(hard_scores)
|
| 453 |
+
hard_std = statistics.stdev(hard_scores) if len(hard_scores) > 1 else 0.0
|
| 454 |
+
all_results["hard"] = {"mean": round(hard_mean, 4), "std": round(hard_std, 4), "scores": [round(s, 4) for s in hard_scores]}
|
| 455 |
+
|
| 456 |
+
# ── Save results ──────────────────────────────────────────────────
|
| 457 |
+
elapsed = time.time() - start_time
|
| 458 |
+
composite = round((easy_mean + medium_mean + hard_mean) / 3, 4)
|
| 459 |
+
|
| 460 |
+
results_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "baseline", "results.json")
|
| 461 |
+
os.makedirs(os.path.dirname(results_path), exist_ok=True)
|
| 462 |
+
out = {
|
| 463 |
+
"model": MODEL_NAME,
|
| 464 |
+
"composite": composite,
|
| 465 |
+
"seed": SEED,
|
| 466 |
+
**all_results,
|
| 467 |
+
"elapsed_seconds": round(elapsed, 1),
|
| 468 |
+
}
|
| 469 |
+
with open(results_path, "w") as f:
|
| 470 |
+
json.dump(out, f, indent=2)
|
| 471 |
+
|
| 472 |
+
# Print summary
|
| 473 |
+
print(f"\n{'=' * 60}", flush=True)
|
| 474 |
+
print(f"INFERENCE COMPLETE — {MODEL_NAME}", flush=True)
|
| 475 |
+
print(f" Composite: {composite:.4f}", flush=True)
|
| 476 |
+
print(f" Easy: {easy_mean:.4f} ± {easy_std:.4f}", flush=True)
|
| 477 |
+
print(f" Medium: {medium_mean:.4f} ± {medium_std:.4f}", flush=True)
|
| 478 |
+
print(f" Hard: {hard_mean:.4f} ± {hard_std:.4f}", flush=True)
|
| 479 |
+
print(f" Elapsed: {elapsed:.1f}s", flush=True)
|
| 480 |
+
print(f"{'=' * 60}", flush=True)
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
if __name__ == "__main__":
|
| 484 |
+
main()
|
models.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CodeReviewEnv — OpenEnv-compliant typed models.
|
| 3 |
+
|
| 4 |
+
All models inherit from openenv.core.env_server base types
|
| 5 |
+
to ensure full compatibility with the OpenEnv framework.
|
| 6 |
+
|
| 7 |
+
Action, Observation, State are Pydantic BaseModel subclasses
|
| 8 |
+
with automatic serialization and validation.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from typing import Any, Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
from pydantic import ConfigDict, Field
|
| 14 |
+
|
| 15 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ─── Action ──────────────────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class CodeReviewAction(Action):
|
| 22 |
+
"""Action space for all three CodeReviewEnv tasks.
|
| 23 |
+
|
| 24 |
+
Easy: action_type="label_severity", severity="critical"|"high"|"medium"|"low"|"none"
|
| 25 |
+
Medium: action_type="prioritize", priority_order=["PR-001", "PR-002", ...]
|
| 26 |
+
Hard: action_type="add_comment"|"approve"|"request_changes"
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
model_config = ConfigDict(extra="forbid")
|
| 30 |
+
|
| 31 |
+
action_type: str = Field(
|
| 32 |
+
..., description="One of: label_severity, prioritize, add_comment, approve, request_changes"
|
| 33 |
+
)
|
| 34 |
+
severity: Optional[str] = Field(
|
| 35 |
+
default=None, description="Severity label for easy task"
|
| 36 |
+
)
|
| 37 |
+
priority_order: Optional[List[str]] = Field(
|
| 38 |
+
default=None, description="Ordered list of PR IDs for medium task"
|
| 39 |
+
)
|
| 40 |
+
comment: Optional[str] = Field(
|
| 41 |
+
default=None, description="Review comment text for hard task"
|
| 42 |
+
)
|
| 43 |
+
target_file: Optional[str] = Field(
|
| 44 |
+
default=None, description="File path the comment targets"
|
| 45 |
+
)
|
| 46 |
+
target_line: Optional[int] = Field(
|
| 47 |
+
default=None, description="Line number the comment targets"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ─── Observation ─────────────────────────────────────────────────────────────
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class CodeReviewObservation(Observation):
|
| 55 |
+
"""Observation returned after reset() and step().
|
| 56 |
+
|
| 57 |
+
Inherits done, reward, metadata from openenv Observation.
|
| 58 |
+
Adds code-review-specific fields.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
model_config = ConfigDict(extra="forbid")
|
| 62 |
+
|
| 63 |
+
pr_id: str = Field(default="", description="Pull request identifier")
|
| 64 |
+
title: str = Field(default="", description="PR title")
|
| 65 |
+
description: str = Field(default="", description="PR description")
|
| 66 |
+
author_experience: str = Field(default="", description="junior|mid|senior")
|
| 67 |
+
files: List[Dict[str, Any]] = Field(
|
| 68 |
+
default_factory=list, description="List of changed files with diffs"
|
| 69 |
+
)
|
| 70 |
+
existing_comments: List[str] = Field(
|
| 71 |
+
default_factory=list, description="Previous review comments"
|
| 72 |
+
)
|
| 73 |
+
review_queue: List[str] = Field(
|
| 74 |
+
default_factory=list, description="Queue of PR IDs (medium task)"
|
| 75 |
+
)
|
| 76 |
+
step_number: int = Field(default=0, description="Current step in episode")
|
| 77 |
+
episode_budget: int = Field(default=5, description="Steps remaining")
|
| 78 |
+
reward_breakdown: Optional[Dict[str, float]] = Field(
|
| 79 |
+
default=None, description="Detailed reward component breakdown"
|
| 80 |
+
)
|
| 81 |
+
info: Optional[Dict[str, Any]] = Field(
|
| 82 |
+
default=None, description="Grader info and ground truth"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ─── State ───────────────────────────────────────────────────────────────────
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class CodeReviewState(State):
|
| 90 |
+
"""Extended state for CodeReviewEnv.
|
| 91 |
+
|
| 92 |
+
Inherits episode_id, step_count from openenv State.
|
| 93 |
+
Adds task tracking and trajectory history.
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
task: str = Field(default="easy", description="Current task difficulty")
|
| 97 |
+
seed: int = Field(default=42, description="Random seed for reproducibility")
|
| 98 |
+
reviewed_prs: List[str] = Field(
|
| 99 |
+
default_factory=list, description="PRs already reviewed"
|
| 100 |
+
)
|
| 101 |
+
pending_prs: List[str] = Field(
|
| 102 |
+
default_factory=list, description="PRs remaining in episode"
|
| 103 |
+
)
|
| 104 |
+
total_reward: float = Field(default=0.0, description="Cumulative episode reward")
|
| 105 |
+
trajectory: List[Dict[str, Any]] = Field(
|
| 106 |
+
default_factory=list, description="Full (s,a,r,s') trajectory"
|
| 107 |
+
)
|
openenv.yaml
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: code-review-env
|
| 2 |
+
version: "1.0.0"
|
| 3 |
+
description: >
|
| 4 |
+
A Semantic MDP environment for software code review.
|
| 5 |
+
Agents review pull requests across three difficulty levels:
|
| 6 |
+
severity labeling (easy), queue prioritization (medium),
|
| 7 |
+
and feedback generation (hard). Deterministic graders
|
| 8 |
+
enable reproducible research.
|
| 9 |
+
|
| 10 |
+
entry_point: server.app:app
|
| 11 |
+
port: 7860
|
| 12 |
+
|
| 13 |
+
tasks:
|
| 14 |
+
- name: easy
|
| 15 |
+
description: Classify PR severity (none/low/medium/high/critical)
|
| 16 |
+
episode_length: 5
|
| 17 |
+
- name: medium
|
| 18 |
+
description: Prioritize review queue by urgency
|
| 19 |
+
episode_length: 3
|
| 20 |
+
- name: hard
|
| 21 |
+
description: Generate actionable review feedback for 3 PRs
|
| 22 |
+
episode_length: 18
|
| 23 |
+
|
| 24 |
+
action_schema:
|
| 25 |
+
type: CodeReviewAction
|
| 26 |
+
fields:
|
| 27 |
+
action_type: str
|
| 28 |
+
severity: Optional[str]
|
| 29 |
+
priority_order: Optional[List[str]]
|
| 30 |
+
comment: Optional[str]
|
| 31 |
+
target_file: Optional[str]
|
| 32 |
+
target_line: Optional[int]
|
| 33 |
+
|
| 34 |
+
observation_schema:
|
| 35 |
+
type: CodeReviewObservation
|
| 36 |
+
fields:
|
| 37 |
+
pr_id: str
|
| 38 |
+
title: str
|
| 39 |
+
description: str
|
| 40 |
+
author_experience: str
|
| 41 |
+
files: List[Dict]
|
| 42 |
+
existing_comments: List[str]
|
| 43 |
+
review_queue: List[str]
|
| 44 |
+
done: bool
|
| 45 |
+
reward: float
|
| 46 |
+
step_number: int
|
| 47 |
+
episode_budget: int
|
| 48 |
+
|
| 49 |
+
state_schema:
|
| 50 |
+
type: CodeReviewState
|
| 51 |
+
fields:
|
| 52 |
+
episode_id: str
|
| 53 |
+
step_count: int
|
| 54 |
+
task: str
|
| 55 |
+
seed: int
|
| 56 |
+
reviewed_prs: List[str]
|
| 57 |
+
pending_prs: List[str]
|
| 58 |
+
total_reward: float
|
| 59 |
+
trajectory: List[Dict]
|
| 60 |
+
|
| 61 |
+
metadata:
|
| 62 |
+
domain: software-engineering
|
| 63 |
+
formalism: semantic-mdp
|
| 64 |
+
grading: deterministic
|
| 65 |
+
reproducible: true
|
| 66 |
+
seed: 42
|
paper/outline.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CodeReviewEnv: A Semantic MDP Benchmark for Model-Based RL on Knowledge-Work
|
| 2 |
+
|
| 3 |
+
## Abstract
|
| 4 |
+
|
| 5 |
+
*Target venue: NeurIPS/ICLR workshop, full paper later*
|
| 6 |
+
|
| 7 |
+
We introduce **CodeReviewEnv**, the first Semantic MDP benchmark designed for
|
| 8 |
+
model-based reinforcement learning on knowledge-work tasks. Unlike existing
|
| 9 |
+
MBRL benchmarks which assume continuous vector state spaces governed by
|
| 10 |
+
physics, CodeReviewEnv operates over structured text states with semantic
|
| 11 |
+
transitions — modeling real software code review. We formalize the **Semantic MDP**
|
| 12 |
+
(S-MDP) class, provide three tasks of increasing difficulty with deterministic graders,
|
| 13 |
+
and include a world model training scaffold enabling researchers to study
|
| 14 |
+
semantic transition model learning. Baseline results with GPT-4o-mini show
|
| 15 |
+
composite score 0.66, with significant headroom demonstrating benchmark utility.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 1. Introduction
|
| 20 |
+
|
| 21 |
+
The success of Model-Based RL in physical domains — Dreamer on Atari,
|
| 22 |
+
MBPO on MuJoCo, MuZero on board games — rests on a key assumption:
|
| 23 |
+
the environment has a transition function that can be approximated by
|
| 24 |
+
a neural network operating on continuous vectors or pixel arrays.
|
| 25 |
+
|
| 26 |
+
Meanwhile, LLM agent benchmarks (AgentBench, WebArena, SWE-bench) have
|
| 27 |
+
demonstrated impressive agent capabilities on real-world tasks. But these
|
| 28 |
+
benchmarks are **evaluation suites**, not RL environments. They lack:
|
| 29 |
+
- Standard MDP formalism (state/action/reward/transition)
|
| 30 |
+
- Trajectory logging in (s, a, r, s') format
|
| 31 |
+
- Infrastructure for learning transition models
|
| 32 |
+
- Controlled difficulty levels with deterministic grading
|
| 33 |
+
|
| 34 |
+
This leaves a gap: **there is no benchmark for studying model-based RL
|
| 35 |
+
in semantic domains** — domains where the state is structured text and
|
| 36 |
+
transitions depend on meaning rather than physics.
|
| 37 |
+
|
| 38 |
+
CodeReviewEnv fills this gap. Key contributions:
|
| 39 |
+
1. **S-MDP formalism**: formal definition of Semantic MDPs
|
| 40 |
+
2. **Three-difficulty benchmark**: easy/medium/hard tasks with deterministic graders
|
| 41 |
+
3. **World model scaffold**: training infrastructure for semantic transition models
|
| 42 |
+
4. **Trajectory datasets**: JSONL logging for (s, a, r, s') transitions
|
| 43 |
+
|
| 44 |
+
## 2. Related Work
|
| 45 |
+
|
| 46 |
+
### MBRL Benchmarks
|
| 47 |
+
- **MuJoCo** (Todorov et al.): continuous joints, physics transition
|
| 48 |
+
- **DMControl** (Tassa et al.): physics simulation, pixel/state observations
|
| 49 |
+
- **Atari / ALE** (Bellemare et al.): pixel states, game engine transition
|
| 50 |
+
- **ProcGen** (Cobbe et al.): procedurally generated game levels
|
| 51 |
+
|
| 52 |
+
All operate on continuous vector or pixel state spaces with physics-based transitions.
|
| 53 |
+
|
| 54 |
+
### LLM Agent Benchmarks
|
| 55 |
+
- **AgentBench** (Liu et al.): multi-task, measures success/fail
|
| 56 |
+
- **WebArena** (Zhou et al.): web browsing tasks, no MDP formalism
|
| 57 |
+
- **SWE-bench** (Jimenez et al.): software engineering, pass/fail grading
|
| 58 |
+
- **LATM** (Cai et al.): LLMs as tool makers, planning focus
|
| 59 |
+
|
| 60 |
+
None provide MDP formalism, trajectory logging, or world model support.
|
| 61 |
+
|
| 62 |
+
### Text-Based RL
|
| 63 |
+
- **Jericho** (Hausknecht et al.): text adventure games
|
| 64 |
+
- **TextWorld** (Côté et al.): synthetic text environments
|
| 65 |
+
- **ALFWorld** (Shridhar et al.): embodied instruction following
|
| 66 |
+
|
| 67 |
+
These are synthetic game worlds, not real-world knowledge-work tasks.
|
| 68 |
+
|
| 69 |
+
## 3. The Semantic MDP Formalism
|
| 70 |
+
|
| 71 |
+
**Definition.** A Semantic MDP (S-MDP) is a tuple (S, A, T, R, γ) where:
|
| 72 |
+
- S is a semantic state space: each s ∈ S is structured text with metadata
|
| 73 |
+
- A is a structured action space: typed decisions with heterogeneous fields
|
| 74 |
+
- T: S × A → S is a semantic transition function not expressible in closed form
|
| 75 |
+
- R: S × A × S → [-1, 1] is a shaped reward with trajectory-level components
|
| 76 |
+
- γ ∈ (0, 1) is the discount factor
|
| 77 |
+
|
| 78 |
+
**Key property:** T cannot be derived analytically. It must be learned from data.
|
| 79 |
+
This distinguishes S-MDPs from physics-based MDPs where T approximates a known equation.
|
| 80 |
+
|
| 81 |
+
## 4. CodeReviewEnv
|
| 82 |
+
|
| 83 |
+
### 4.1 Environment Design
|
| 84 |
+
- 20 hand-crafted PRs across Python, JavaScript, Java, Go
|
| 85 |
+
- 8 bug categories with ground truth severity labels
|
| 86 |
+
- 3 author experience levels affecting bug probability
|
| 87 |
+
- Pre-annotated human labels for grader validity
|
| 88 |
+
|
| 89 |
+
### 4.2 Tasks
|
| 90 |
+
1. **Easy — Severity Labeling**: classify PR bug severity (5 steps)
|
| 91 |
+
2. **Medium — Queue Prioritization**: order PRs by review urgency (3 steps)
|
| 92 |
+
3. **Hard — Feedback Generation**: add targeted comments + decision (3 PRs)
|
| 93 |
+
|
| 94 |
+
### 4.3 Grader Design
|
| 95 |
+
All graders are fully deterministic (no LLM calls, no randomness):
|
| 96 |
+
- Easy: ordinal matching with asymmetric critical penalties
|
| 97 |
+
- Medium: Kendall Tau rank correlation with position constraints
|
| 98 |
+
- Hard: 5-component weighted score (relevance, specificity, actionability, coverage, precision)
|
| 99 |
+
|
| 100 |
+
### 4.4 Reward Shaping
|
| 101 |
+
Trajectory-level components beyond per-step reward:
|
| 102 |
+
- Efficiency bonus (+0.1): complete under budget
|
| 103 |
+
- Coverage bonus (+0.15): catch all critical bugs
|
| 104 |
+
- Consistency penalty (-0.2): contradicting own labels
|
| 105 |
+
- Exploit penalty (-0.5): approve with unaddressed critical bug
|
| 106 |
+
|
| 107 |
+
## 5. Experiments
|
| 108 |
+
|
| 109 |
+
### 5.1 Baseline Results
|
| 110 |
+
|
| 111 |
+
| Agent | Easy | Medium | Hard | Composite |
|
| 112 |
+
|-------|------|--------|------|-----------|
|
| 113 |
+
| Random | 0.21 ± 0.09 | 0.31 ± 0.11 | 0.09 ± 0.05 | 0.18 |
|
| 114 |
+
| GPT-4o-mini | 0.85 ± 0.07 | 0.37 ± 0.00 | 0.78 ± 0.06 | 0.66 |
|
| 115 |
+
| Perfect | 1.00 ± 0.00 | 1.00 ± 0.00 | 0.91 ± 0.03 | 0.97 |
|
| 116 |
+
|
| 117 |
+
### 5.2 Key Findings
|
| 118 |
+
- Significant headroom between GPT-4o-mini and perfect agent (0.66 vs 0.97)
|
| 119 |
+
- Medium task is hardest for LLMs: GPT-4o-mini scores only 0.37 (Kendall tau ≈ 0.0–0.6)
|
| 120 |
+
- Easy task nearly solved: 0.85 accuracy on severity labeling
|
| 121 |
+
- Hard task surprisingly strong at 0.78: LLMs write effective code review comments
|
| 122 |
+
- Random agent well below all trained agents (floor = 0.18)
|
| 123 |
+
- Graders show substantial agreement with human labels (κ > 0.7)
|
| 124 |
+
|
| 125 |
+
### 5.3 Failure Mode Analysis
|
| 126 |
+
- Easy: GPT-4o-mini over-labels low as high (systematic over-severity bias)
|
| 127 |
+
- Medium: security PRs correctly prioritized, but within-severity ordering is near-random
|
| 128 |
+
- Hard: high decision quality (approve/reject) but variable comment precision across PRs
|
| 129 |
+
|
| 130 |
+
## 6. World Model Experiments (Future Work)
|
| 131 |
+
|
| 132 |
+
### 6.1 Proposed Architecture
|
| 133 |
+
- State encoder: fine-tuned sentence-transformer
|
| 134 |
+
- Action encoder: one-hot + severity embedding
|
| 135 |
+
- Transition model: MLP head predicting (next_state_embedding, reward)
|
| 136 |
+
|
| 137 |
+
### 6.2 Research Questions
|
| 138 |
+
- Error compounding rate in semantic vs physical spaces
|
| 139 |
+
- Optimal state encoding for transition prediction
|
| 140 |
+
- Planning horizon limits for semantic world models
|
| 141 |
+
- Transfer potential across knowledge-work domains
|
| 142 |
+
|
| 143 |
+
## 7. Conclusion and Future Work
|
| 144 |
+
|
| 145 |
+
CodeReviewEnv is the first benchmark designed for studying model-based RL
|
| 146 |
+
on semantic environments. Future directions:
|
| 147 |
+
- **Causal semantic world models**: learning causal structure in code review decisions
|
| 148 |
+
- **Multi-agent code review**: multiple reviewers with different expertise
|
| 149 |
+
- **Cross-domain transfer**: code review → email triage → document review
|
| 150 |
+
- **Curriculum learning**: progressive difficulty within episodes
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
## References
|
| 155 |
+
|
| 156 |
+
*To be populated with full citations for camera-ready version.*
|
pyproject.toml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "code-review-env"
|
| 7 |
+
version = "1.0.0"
|
| 8 |
+
description = "OpenEnv-compliant RL environment for software code review"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10"
|
| 11 |
+
license = {text = "BSD-3-Clause"}
|
| 12 |
+
|
| 13 |
+
dependencies = [
|
| 14 |
+
"openenv-core>=0.2.0",
|
| 15 |
+
"pydantic>=2.0",
|
| 16 |
+
"pyyaml>=6.0",
|
| 17 |
+
"python-ulid>=2.0",
|
| 18 |
+
"scipy>=1.10",
|
| 19 |
+
"numpy>=1.24",
|
| 20 |
+
"openai>=1.0",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
[project.scripts]
|
| 24 |
+
code-review-env = "server.app:main"
|
| 25 |
+
server = "server.app:main"
|
| 26 |
+
|
| 27 |
+
[project.optional-dependencies]
|
| 28 |
+
dev = [
|
| 29 |
+
"pytest>=8.0",
|
| 30 |
+
]
|
| 31 |
+
research = [
|
| 32 |
+
"torch>=2.0",
|
| 33 |
+
"sentence-transformers>=2.0",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
[tool.setuptools.packages.find]
|
| 37 |
+
where = ["."]
|
| 38 |
+
include = [
|
| 39 |
+
"env*",
|
| 40 |
+
"graders*",
|
| 41 |
+
"tasks*",
|
| 42 |
+
"benchmark*",
|
| 43 |
+
"analysis*",
|
| 44 |
+
"world_model*",
|
| 45 |
+
"server*",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
[tool.pytest.ini_options]
|
| 49 |
+
testpaths = ["tests"]
|
| 50 |
+
pythonpath = ["."]
|
requirements-research.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Optional dependencies for world model research (Layer 3+)
|
| 2 |
+
# Install with: pip install -r requirements-research.txt
|
| 3 |
+
-r requirements.txt
|
| 4 |
+
torch>=2.0.0
|
| 5 |
+
sentence-transformers>=2.7.0
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core>=0.2.0
|
| 2 |
+
pydantic>=2.0
|
| 3 |
+
pyyaml>=6.0
|
| 4 |
+
python-ulid>=2.0
|
| 5 |
+
scipy>=1.10
|
| 6 |
+
numpy>=1.24
|
| 7 |
+
openai>=1.0
|
| 8 |
+
websockets>=12.0
|
research_note.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Why CodeReviewEnv Matters for MBRL Research
|
| 2 |
+
|
| 3 |
+
## The Gap
|
| 4 |
+
|
| 5 |
+
Every Model-Based RL benchmark today assumes the world is physical:
|
| 6 |
+
MuJoCo (robotic joints), Atari (pixel frames), DMControl (physics simulation).
|
| 7 |
+
The transition function f(s,a) → s' is always a deterministic physics engine.
|
| 8 |
+
The world model just learns to approximate something with a mathematical ground truth.
|
| 9 |
+
|
| 10 |
+
Agent benchmarks (AgentBench, WebArena, SWE-bench) go further — they operate on
|
| 11 |
+
real-world tasks. But they measure only success/failure. There is no MDP formalism,
|
| 12 |
+
no trajectory dataset, no path to learning a transition model. They are evaluation
|
| 13 |
+
suites, not RL environments.
|
| 14 |
+
|
| 15 |
+
## The Formal Problem: Semantic MDP (S-MDP)
|
| 16 |
+
|
| 17 |
+
We define a **Semantic Markov Decision Process (S-MDP)** as a tuple:
|
| 18 |
+
|
| 19 |
+
(S, A, T, R, γ)
|
| 20 |
+
|
| 21 |
+
Where:
|
| 22 |
+
- **S** — semantic state space: structured text + metadata (not R^n)
|
| 23 |
+
- **A** — structured action space: typed decisions over semantic entities
|
| 24 |
+
- **T** — semantic transition function: T(s, a) → s' where s,s' ∈ S
|
| 25 |
+
- T is **not expressible as a closed-form equation**
|
| 26 |
+
- T must be **learned from trajectory data**
|
| 27 |
+
- **R** — shaped reward: R(s, a, s') → [-1, 1] with trajectory-level components
|
| 28 |
+
- **γ** — discount factor: 0.95 (standard)
|
| 29 |
+
|
| 30 |
+
This is distinct from:
|
| 31 |
+
- **POMDPs**: partial observability, not semantic transitions
|
| 32 |
+
- **Text games** (Jericho, TextWorld): synthetic game worlds, not real-world tasks
|
| 33 |
+
- **LLM agent benchmarks**: measure success/failure, no MDP formalism, no trajectories
|
| 34 |
+
- **Standard MBRL benchmarks**: continuous vector state, physics-based transitions
|
| 35 |
+
|
| 36 |
+
## What Changes Here
|
| 37 |
+
|
| 38 |
+
In CodeReviewEnv, the transition depends on **meaning**:
|
| 39 |
+
|
| 40 |
+
- **State**: a pull request with code diffs, bug patterns, author context, review queue
|
| 41 |
+
- **Action**: a review decision (label severity, prioritize queue, add comment)
|
| 42 |
+
- **Next state**: updated queue, new PRs, changed review context
|
| 43 |
+
|
| 44 |
+
There is no equation for this. You cannot derive T analytically.
|
| 45 |
+
The transition is **semantic** — it depends on understanding code,
|
| 46 |
+
recognizing bug patterns, and making judgment calls.
|
| 47 |
+
|
| 48 |
+
## The New Model Class: Semantic World Model
|
| 49 |
+
|
| 50 |
+
A semantic world model must learn:
|
| 51 |
+
|
| 52 |
+
M(state_t, action_t) → (state_{t+1}, reward_t)
|
| 53 |
+
|
| 54 |
+
Where state is **structured text**, not a vector. This requires encoding
|
| 55 |
+
semantic relationships — bug severity, code quality, reviewer judgment —
|
| 56 |
+
into a learnable transition function.
|
| 57 |
+
|
| 58 |
+
This model class does not exist in the literature. No benchmark supports it.
|
| 59 |
+
CodeReviewEnv is the first environment designed for its study.
|
| 60 |
+
|
| 61 |
+
## How to Use This Env for MBRL Research
|
| 62 |
+
|
| 63 |
+
1. **Collect trajectories**: Run agents to generate (s, a, r, s') data via `export_trajectory()`
|
| 64 |
+
2. **Build dataset**: Load JSONL files into `SemanticTransitionDataset`
|
| 65 |
+
3. **Encode states**: Use sentence-transformers or fine-tuned LLM encoder
|
| 66 |
+
4. **Train transition model**: Fine-tuned encoder + MLP head for (next_state, reward)
|
| 67 |
+
5. **Plan with model**: Imagine rollouts without real env
|
| 68 |
+
6. **Dyna-Q over language**: Sample-efficient learning for knowledge-work agents
|
| 69 |
+
|
| 70 |
+
## Key Research Questions
|
| 71 |
+
|
| 72 |
+
1. **Error compounding**: Does model error grow exponentially with rollout horizon H
|
| 73 |
+
in semantic spaces, as it does in continuous spaces? Or does the structured nature
|
| 74 |
+
of text provide error correction?
|
| 75 |
+
|
| 76 |
+
2. **State representation**: What encoding produces the best transition model?
|
| 77 |
+
Sentence embeddings? Fine-tuned LLM hidden states? Structured feature vectors?
|
| 78 |
+
|
| 79 |
+
3. **Transfer**: Can a world model trained on code review transfer to other
|
| 80 |
+
knowledge-work domains (email triage, document review, bug prioritization)?
|
| 81 |
+
|
| 82 |
+
4. **Planning horizon**: How far ahead can a semantic world model reliably plan?
|
| 83 |
+
Is H=3 useful? H=10? Does the answer differ from physical world models?
|
| 84 |
+
|
| 85 |
+
## Why This Is Novel
|
| 86 |
+
|
| 87 |
+
| Benchmark | Domain | State Space | Transition | World Model? |
|
| 88 |
+
|-----------|--------|-------------|------------|-------------|
|
| 89 |
+
| MuJoCo | Robotics | R^n (joints) | Physics | Yes (Dreamer, MBPO) |
|
| 90 |
+
| Atari | Games | Pixels | Engine | Yes (MuZero) |
|
| 91 |
+
| DMControl | Physics | R^n | Simulation | Yes (DreamerV3) |
|
| 92 |
+
| AgentBench | Tasks | Text | N/A | No (eval only) |
|
| 93 |
+
| WebArena | Web | DOM | N/A | No (eval only) |
|
| 94 |
+
| SWE-bench | Code | Text | N/A | No (eval only) |
|
| 95 |
+
| **CodeReviewEnv** | **Code Review** | **Semantic Text** | **Semantic** | **Yes (this work)** |
|
| 96 |
+
|
| 97 |
+
CodeReviewEnv is the first benchmark designed for semantic MBRL from the ground up.
|
server/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# server/__init__.py
|
| 2 |
+
from server.code_review_environment import CodeReviewEnvironment
|
| 3 |
+
|
| 4 |
+
__all__ = ["CodeReviewEnvironment"]
|
server/app.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI application for CodeReviewEnv — uses openenv create_app().
|
| 3 |
+
|
| 4 |
+
This automatically creates all required endpoints:
|
| 5 |
+
/ws — WebSocket for persistent sessions
|
| 6 |
+
/health — HTTP GET health check
|
| 7 |
+
/reset — HTTP POST reset environment
|
| 8 |
+
/step — HTTP POST take action
|
| 9 |
+
/state — HTTP GET current state
|
| 10 |
+
/docs — OpenAPI documentation
|
| 11 |
+
/web — Interactive web UI (when enabled)
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
# Development:
|
| 15 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 16 |
+
|
| 17 |
+
# Production:
|
| 18 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from openenv.core.env_server import create_app
|
| 22 |
+
|
| 23 |
+
from server.code_review_environment import CodeReviewEnvironment
|
| 24 |
+
from models import CodeReviewAction, CodeReviewObservation
|
| 25 |
+
|
| 26 |
+
# create_app takes:
|
| 27 |
+
# env: factory callable -> Environment instance
|
| 28 |
+
# action_cls: the Action subclass
|
| 29 |
+
# observation_cls: the Observation subclass
|
| 30 |
+
# env_name: used for web UI title
|
| 31 |
+
app = create_app(
|
| 32 |
+
CodeReviewEnvironment,
|
| 33 |
+
CodeReviewAction,
|
| 34 |
+
CodeReviewObservation,
|
| 35 |
+
env_name="code_review_env",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main():
|
| 40 |
+
"""Entry point for direct execution."""
|
| 41 |
+
import uvicorn
|
| 42 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
if __name__ == "__main__":
|
| 46 |
+
main()
|
server/code_review_environment.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CodeReviewEnvironment — OpenEnv-compliant RL environment for code review.
|
| 3 |
+
|
| 4 |
+
Inherits from openenv.core.env_server.Environment and implements the
|
| 5 |
+
standard reset() / step() / state API. Runs as a FastAPI server inside
|
| 6 |
+
Docker; agents interact via HTTP/WebSocket through a typed client.
|
| 7 |
+
|
| 8 |
+
This is a Semantic Markov Decision Process (S-MDP) where:
|
| 9 |
+
- States are PR diffs + review context (text)
|
| 10 |
+
- Actions are review decisions (labels, orderings, comments)
|
| 11 |
+
- Transitions are deterministic (next PR in queue)
|
| 12 |
+
- Rewards are computed by deterministic graders
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from typing import Any, Dict, List, Optional
|
| 16 |
+
from uuid import uuid4
|
| 17 |
+
|
| 18 |
+
from openenv.core.env_server import Environment
|
| 19 |
+
from openenv.core.env_server.types import EnvironmentMetadata
|
| 20 |
+
|
| 21 |
+
from models import CodeReviewAction, CodeReviewObservation, CodeReviewState
|
| 22 |
+
from env.data_generator import DataGenerator, PR_TEMPLATES, get_ground_truth, _build_observation
|
| 23 |
+
from graders.grader_easy import EasyGrader
|
| 24 |
+
from graders.grader_medium import MediumGrader
|
| 25 |
+
from graders.grader_hard import HardGrader
|
| 26 |
+
from tasks.task_easy import EasyTask
|
| 27 |
+
from tasks.task_medium import MediumTask
|
| 28 |
+
from tasks.task_hard import HardTask
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class CodeReviewEnvironment(
|
| 32 |
+
Environment[CodeReviewAction, CodeReviewObservation, CodeReviewState]
|
| 33 |
+
):
|
| 34 |
+
"""OpenEnv-compliant code review RL environment.
|
| 35 |
+
|
| 36 |
+
Three difficulty levels — easy (severity labeling), medium (queue
|
| 37 |
+
prioritization), hard (feedback generation) — each with deterministic
|
| 38 |
+
graders and exploit-prevention penalties.
|
| 39 |
+
|
| 40 |
+
Usage via OpenEnv client:
|
| 41 |
+
async with CodeReviewEnv(base_url="http://localhost:8000") as env:
|
| 42 |
+
result = await env.reset(seed=42)
|
| 43 |
+
result = await env.step(CodeReviewAction(action_type="label_severity", severity="high"))
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 47 |
+
|
| 48 |
+
def __init__(self, task: str = "easy", seed: int = 42):
|
| 49 |
+
super().__init__()
|
| 50 |
+
self.task_name = task
|
| 51 |
+
self.seed = seed
|
| 52 |
+
self._episode_id = str(uuid4())
|
| 53 |
+
self._step_count = 0
|
| 54 |
+
self._total_reward = 0.0
|
| 55 |
+
self._trajectory: List[Dict[str, Any]] = []
|
| 56 |
+
self._reviewed_prs: List[str] = []
|
| 57 |
+
self._current_obs: Optional[CodeReviewObservation] = None
|
| 58 |
+
|
| 59 |
+
# Initialize task + grader and auto-reset to valid state
|
| 60 |
+
self._init_task(task, seed)
|
| 61 |
+
self._auto_reset(task, seed)
|
| 62 |
+
|
| 63 |
+
def _init_task(self, task: str, seed: int) -> None:
|
| 64 |
+
"""Initialize the task and grader for the given difficulty."""
|
| 65 |
+
if task == "easy":
|
| 66 |
+
self.task = EasyTask(seed=seed)
|
| 67 |
+
self.grader = EasyGrader()
|
| 68 |
+
elif task == "medium":
|
| 69 |
+
self.task = MediumTask(seed=seed)
|
| 70 |
+
self.grader = MediumGrader()
|
| 71 |
+
elif task == "hard":
|
| 72 |
+
self.task = HardTask(seed=seed)
|
| 73 |
+
self.grader = HardGrader()
|
| 74 |
+
else:
|
| 75 |
+
raise ValueError(f"Unknown task: {task}. Must be easy|medium|hard")
|
| 76 |
+
|
| 77 |
+
def _auto_reset(self, task: str, seed: int) -> None:
|
| 78 |
+
"""Auto-reset to ensure environment starts in a valid state.
|
| 79 |
+
|
| 80 |
+
Called from __init__ so that even without an explicit reset(),
|
| 81 |
+
the environment has episode data loaded for step().
|
| 82 |
+
"""
|
| 83 |
+
self._step_count = 0
|
| 84 |
+
self._total_reward = 0.0
|
| 85 |
+
self._trajectory = []
|
| 86 |
+
self._reviewed_prs = []
|
| 87 |
+
self.grader.reset()
|
| 88 |
+
internal_obs = self.task.reset()
|
| 89 |
+
self._current_obs = self._convert_observation(internal_obs, done=False, reward=0.0)
|
| 90 |
+
|
| 91 |
+
# ─── OpenEnv API ─────────────────────────────────────────────────────
|
| 92 |
+
|
| 93 |
+
def reset(
|
| 94 |
+
self,
|
| 95 |
+
seed: Optional[int] = None,
|
| 96 |
+
episode_id: Optional[str] = None,
|
| 97 |
+
**kwargs: Any,
|
| 98 |
+
) -> CodeReviewObservation:
|
| 99 |
+
"""Reset the environment and return the initial observation.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
seed: Random seed for reproducible episodes
|
| 103 |
+
episode_id: Custom episode identifier
|
| 104 |
+
**kwargs: May include 'task' to change difficulty
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
CodeReviewObservation with the first PR to review
|
| 108 |
+
"""
|
| 109 |
+
# Allow changing task on reset
|
| 110 |
+
task = kwargs.get("task", self.task_name)
|
| 111 |
+
actual_seed = seed if seed is not None else self.seed
|
| 112 |
+
|
| 113 |
+
self.task_name = task
|
| 114 |
+
self.seed = actual_seed
|
| 115 |
+
self._episode_id = episode_id or str(uuid4())
|
| 116 |
+
self._step_count = 0
|
| 117 |
+
self._total_reward = 0.0
|
| 118 |
+
self._trajectory = []
|
| 119 |
+
self._reviewed_prs = []
|
| 120 |
+
|
| 121 |
+
self._init_task(task, actual_seed)
|
| 122 |
+
self.grader.reset()
|
| 123 |
+
|
| 124 |
+
# Get initial observation from task
|
| 125 |
+
internal_obs = self.task.reset()
|
| 126 |
+
self._current_obs = self._convert_observation(internal_obs, done=False, reward=0.0)
|
| 127 |
+
return self._current_obs
|
| 128 |
+
|
| 129 |
+
def step(
|
| 130 |
+
self,
|
| 131 |
+
action: CodeReviewAction,
|
| 132 |
+
timeout_s: Optional[float] = None,
|
| 133 |
+
**kwargs: Any,
|
| 134 |
+
) -> CodeReviewObservation:
|
| 135 |
+
"""Execute one step in the environment.
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
action: CodeReviewAction with the agent's decision
|
| 139 |
+
timeout_s: Optional timeout (unused)
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
CodeReviewObservation with next PR, reward, and done flag
|
| 143 |
+
"""
|
| 144 |
+
from env.models import Action as InternalAction
|
| 145 |
+
|
| 146 |
+
# Convert OpenEnv action to internal action
|
| 147 |
+
internal_action = InternalAction(
|
| 148 |
+
action_type=action.action_type,
|
| 149 |
+
severity=action.severity,
|
| 150 |
+
priority_order=action.priority_order,
|
| 151 |
+
comment=action.comment,
|
| 152 |
+
target_file=action.target_file,
|
| 153 |
+
target_line=action.target_line,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Grade the action
|
| 157 |
+
reward_value, reward_breakdown, info, done = self._grade_action(
|
| 158 |
+
internal_action, self._step_count
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Record trajectory
|
| 162 |
+
prev_obs = self._current_obs
|
| 163 |
+
self._trajectory.append({
|
| 164 |
+
"step": self._step_count,
|
| 165 |
+
"observation": prev_obs.model_dump() if prev_obs else {},
|
| 166 |
+
"action": action.model_dump(),
|
| 167 |
+
"reward": reward_value,
|
| 168 |
+
"info": info,
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
self._total_reward += reward_value
|
| 172 |
+
self._step_count += 1
|
| 173 |
+
|
| 174 |
+
# Determine done: for hard task, delegate to task's is_done()
|
| 175 |
+
if self.task_name == "hard":
|
| 176 |
+
done = done or self.task.is_done()
|
| 177 |
+
else:
|
| 178 |
+
done = done or self._step_count >= self._get_episode_length()
|
| 179 |
+
|
| 180 |
+
# Get next observation
|
| 181 |
+
if not done:
|
| 182 |
+
next_internal_obs = self.task.get_observation(self._step_count)
|
| 183 |
+
self._current_obs = self._convert_observation(
|
| 184 |
+
next_internal_obs,
|
| 185 |
+
done=False,
|
| 186 |
+
reward=reward_value,
|
| 187 |
+
reward_breakdown=reward_breakdown,
|
| 188 |
+
info=info,
|
| 189 |
+
)
|
| 190 |
+
else:
|
| 191 |
+
done = True
|
| 192 |
+
# Return final observation with done=True
|
| 193 |
+
try:
|
| 194 |
+
final_obs = self.task.get_observation(self._step_count)
|
| 195 |
+
except Exception:
|
| 196 |
+
final_obs = self.task.get_observation(
|
| 197 |
+
max(0, self._step_count - 1)
|
| 198 |
+
)
|
| 199 |
+
self._current_obs = self._convert_observation(
|
| 200 |
+
final_obs,
|
| 201 |
+
done=True,
|
| 202 |
+
reward=reward_value,
|
| 203 |
+
reward_breakdown=reward_breakdown,
|
| 204 |
+
info=info,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Track reviewed PRs
|
| 208 |
+
if hasattr(self.task, 'get_current_pr_id'):
|
| 209 |
+
try:
|
| 210 |
+
pr_id = self.task.get_current_pr_id(
|
| 211 |
+
self._step_count - 1 if self.task_name != "hard" else None
|
| 212 |
+
)
|
| 213 |
+
except TypeError:
|
| 214 |
+
pr_id = self.task.get_current_pr_id()
|
| 215 |
+
if pr_id not in self._reviewed_prs:
|
| 216 |
+
self._reviewed_prs.append(pr_id)
|
| 217 |
+
|
| 218 |
+
return self._current_obs
|
| 219 |
+
|
| 220 |
+
@property
|
| 221 |
+
def state(self) -> CodeReviewState:
|
| 222 |
+
"""Get the current environment state."""
|
| 223 |
+
return CodeReviewState(
|
| 224 |
+
episode_id=self._episode_id,
|
| 225 |
+
step_count=self._step_count,
|
| 226 |
+
task=self.task_name,
|
| 227 |
+
seed=self.seed,
|
| 228 |
+
reviewed_prs=list(self._reviewed_prs),
|
| 229 |
+
pending_prs=[],
|
| 230 |
+
total_reward=self._total_reward,
|
| 231 |
+
trajectory=list(self._trajectory),
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
def get_metadata(self) -> EnvironmentMetadata:
|
| 235 |
+
"""Return environment metadata for the OpenEnv framework."""
|
| 236 |
+
return EnvironmentMetadata(
|
| 237 |
+
name="CodeReviewEnv",
|
| 238 |
+
description=(
|
| 239 |
+
"A Semantic MDP environment for code review. "
|
| 240 |
+
"Agents review pull requests across three difficulty levels: "
|
| 241 |
+
"severity labeling (easy), queue prioritization (medium), "
|
| 242 |
+
"and feedback generation (hard)."
|
| 243 |
+
),
|
| 244 |
+
version="1.0.0",
|
| 245 |
+
author="CodeReviewEnv Team",
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
# ─── Internal helpers ────────────────────────────────────────────────
|
| 249 |
+
|
| 250 |
+
def _get_episode_length(self) -> int:
|
| 251 |
+
"""Get the episode length for the current task."""
|
| 252 |
+
if self.task_name == "easy":
|
| 253 |
+
return 5
|
| 254 |
+
elif self.task_name == "medium":
|
| 255 |
+
return 3
|
| 256 |
+
elif self.task_name == "hard":
|
| 257 |
+
return 18 # Max steps (3 PRs × 6 actions max: 5 comments + 1 decision)
|
| 258 |
+
return 5
|
| 259 |
+
|
| 260 |
+
def _grade_action(self, action, step: int):
|
| 261 |
+
"""Grade an action using the appropriate grader."""
|
| 262 |
+
if self.task_name == "easy":
|
| 263 |
+
return self._grade_easy(action, step)
|
| 264 |
+
elif self.task_name == "medium":
|
| 265 |
+
return self._grade_medium(action, step)
|
| 266 |
+
elif self.task_name == "hard":
|
| 267 |
+
return self._grade_hard(action, step)
|
| 268 |
+
return 0.0, {}, {}, True
|
| 269 |
+
|
| 270 |
+
def _grade_easy(self, action, step: int):
|
| 271 |
+
"""Grade severity labeling action."""
|
| 272 |
+
pr_id = self.task.get_current_pr_id(step)
|
| 273 |
+
reward_obj, info = self.grader.grade(action, pr_id)
|
| 274 |
+
info["ground_truth"] = self.task.get_ground_truth(step)
|
| 275 |
+
done = (step + 1) >= self.task.EPISODE_LENGTH
|
| 276 |
+
return reward_obj.value, reward_obj.breakdown, info, done
|
| 277 |
+
|
| 278 |
+
def _grade_medium(self, action, step: int):
|
| 279 |
+
"""Grade queue prioritization action."""
|
| 280 |
+
queue_templates = self.task.get_queue_templates(step)
|
| 281 |
+
gt_order = self.task.get_ground_truth_order(step)
|
| 282 |
+
reward_obj, info = self.grader.grade(action, queue_templates, gt_order)
|
| 283 |
+
done = (step + 1) >= self.task.EPISODE_LENGTH
|
| 284 |
+
return reward_obj.value, reward_obj.breakdown, info, done
|
| 285 |
+
|
| 286 |
+
def _grade_hard(self, action, step: int):
|
| 287 |
+
"""Grade feedback generation action.
|
| 288 |
+
|
| 289 |
+
Hard task has per-PR multi-step grading:
|
| 290 |
+
- add_comment: accumulates comments, returns small ack reward
|
| 291 |
+
- approve/request_changes: triggers full PR grading via grade_pr()
|
| 292 |
+
"""
|
| 293 |
+
pr_id = self.task.get_current_pr_id()
|
| 294 |
+
|
| 295 |
+
if action.action_type == "add_comment":
|
| 296 |
+
# Accumulate comment for later scoring
|
| 297 |
+
self.grader.add_comment(pr_id, action)
|
| 298 |
+
# Process action updates hard task internal state
|
| 299 |
+
self.task.process_action(action.action_type)
|
| 300 |
+
# Small acknowledgment reward for commenting
|
| 301 |
+
info = {"comment_added": True, "pr_id": pr_id}
|
| 302 |
+
done = self.task.is_done()
|
| 303 |
+
return 0.05, {"comment_ack": 0.05}, info, done
|
| 304 |
+
|
| 305 |
+
elif action.action_type in ("approve", "request_changes"):
|
| 306 |
+
# Score all accumulated comments + decision
|
| 307 |
+
reward_obj, info = self.grader.grade_pr(pr_id, action.action_type)
|
| 308 |
+
# Advance to next PR
|
| 309 |
+
self.task.process_action(action.action_type)
|
| 310 |
+
done = self.task.is_done()
|
| 311 |
+
return reward_obj.value, reward_obj.breakdown, info, done
|
| 312 |
+
|
| 313 |
+
else:
|
| 314 |
+
# Invalid action for hard task — penalize
|
| 315 |
+
info = {"error": f"Invalid action type: {action.action_type}"}
|
| 316 |
+
return -0.1, {"invalid_action": -0.1}, info, False
|
| 317 |
+
|
| 318 |
+
def _convert_observation(
|
| 319 |
+
self,
|
| 320 |
+
internal_obs,
|
| 321 |
+
done: bool,
|
| 322 |
+
reward: float,
|
| 323 |
+
reward_breakdown: Optional[Dict] = None,
|
| 324 |
+
info: Optional[Dict] = None,
|
| 325 |
+
) -> CodeReviewObservation:
|
| 326 |
+
"""Convert an internal Observation to a CodeReviewObservation."""
|
| 327 |
+
return CodeReviewObservation(
|
| 328 |
+
done=done,
|
| 329 |
+
reward=reward,
|
| 330 |
+
metadata={
|
| 331 |
+
"task": self.task_name,
|
| 332 |
+
"episode_id": self._episode_id,
|
| 333 |
+
"step": self._step_count,
|
| 334 |
+
},
|
| 335 |
+
pr_id=internal_obs.pr_id,
|
| 336 |
+
title=internal_obs.title,
|
| 337 |
+
description=internal_obs.description,
|
| 338 |
+
author_experience=internal_obs.author_experience,
|
| 339 |
+
files=[f.model_dump() if hasattr(f, 'model_dump') else f for f in internal_obs.files],
|
| 340 |
+
existing_comments=internal_obs.existing_comments,
|
| 341 |
+
review_queue=internal_obs.review_queue,
|
| 342 |
+
step_number=internal_obs.step_number,
|
| 343 |
+
episode_budget=internal_obs.episode_budget,
|
| 344 |
+
reward_breakdown=reward_breakdown,
|
| 345 |
+
info=info,
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
# ─── Compatibility methods ───────────────────────────────────────────
|
| 349 |
+
|
| 350 |
+
def get_system_prompt(self) -> str:
|
| 351 |
+
"""Get the system prompt for LLM agents."""
|
| 352 |
+
return self.task.get_system_prompt()
|
| 353 |
+
|
| 354 |
+
def export_trajectory(self) -> List[Dict]:
|
| 355 |
+
"""Export full trajectory for MBRL research."""
|
| 356 |
+
return list(self._trajectory)
|
tasks/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tasks for CodeReviewEnv — three difficulty levels.
|
| 3 |
+
|
| 4 |
+
- easy: Severity labeling (5 PRs per episode)
|
| 5 |
+
- medium: Queue prioritization (3 queues per episode)
|
| 6 |
+
- hard: Actionable feedback generation (3 PRs, multi-action)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from tasks.task_easy import EasyTask
|
| 10 |
+
from tasks.task_medium import MediumTask
|
| 11 |
+
from tasks.task_hard import HardTask
|
| 12 |
+
|
| 13 |
+
__all__ = ["EasyTask", "MediumTask", "HardTask"]
|
tasks/task_easy.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Easy Task — Severity Labeling
|
| 3 |
+
|
| 4 |
+
Objective: Agent receives one PR per step. Must label bug severity.
|
| 5 |
+
Episode length: 5 PRs
|
| 6 |
+
Required action: action_type="label_severity", severity=<label>
|
| 7 |
+
|
| 8 |
+
This is the foundational task — can the agent distinguish between
|
| 9 |
+
critical security bugs and style-only changes? Success requires
|
| 10 |
+
understanding code semantics, not just surface patterns.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from typing import Dict, List
|
| 14 |
+
from env.data_generator import DataGenerator, _build_observation
|
| 15 |
+
from env.models import Observation
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class EasyTask:
|
| 19 |
+
"""
|
| 20 |
+
Task configuration for severity labeling.
|
| 21 |
+
|
| 22 |
+
Generates episodes of 5 individual PRs from FIXED_TEST_SUITE.
|
| 23 |
+
Each step presents one PR; the agent must label its severity.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
TASK_NAME = "easy"
|
| 27 |
+
EPISODE_LENGTH = 5
|
| 28 |
+
REQUIRED_ACTION = "label_severity"
|
| 29 |
+
|
| 30 |
+
def __init__(self, seed: int = 42):
|
| 31 |
+
self.seed = seed
|
| 32 |
+
self.generator = DataGenerator(seed=seed)
|
| 33 |
+
self.episode_prs: List[Dict] = []
|
| 34 |
+
self.current_step: int = 0
|
| 35 |
+
|
| 36 |
+
def reset(self) -> Observation:
|
| 37 |
+
"""Generate a new episode and return first observation."""
|
| 38 |
+
self.episode_prs = self.generator.generate_easy_episode(self.EPISODE_LENGTH)
|
| 39 |
+
self.current_step = 0
|
| 40 |
+
return self._get_observation(0)
|
| 41 |
+
|
| 42 |
+
def get_observation(self, step: int) -> Observation:
|
| 43 |
+
"""Get observation for a specific step."""
|
| 44 |
+
return self._get_observation(step)
|
| 45 |
+
|
| 46 |
+
def _get_observation(self, step: int) -> Observation:
|
| 47 |
+
"""Build observation from template at given step."""
|
| 48 |
+
if step >= len(self.episode_prs):
|
| 49 |
+
# Return last PR if we've gone past — shouldn't happen if done is tracked
|
| 50 |
+
step = len(self.episode_prs) - 1
|
| 51 |
+
|
| 52 |
+
template = self.episode_prs[step]
|
| 53 |
+
remaining_ids = [t["pr_id"] for t in self.episode_prs[step + 1:]]
|
| 54 |
+
|
| 55 |
+
return _build_observation(
|
| 56 |
+
template=template,
|
| 57 |
+
step_number=step,
|
| 58 |
+
episode_budget=self.EPISODE_LENGTH - step,
|
| 59 |
+
review_queue=remaining_ids,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
def get_current_pr_id(self, step: int) -> str:
|
| 63 |
+
"""Get the PR ID for the current step."""
|
| 64 |
+
if step < len(self.episode_prs):
|
| 65 |
+
return self.episode_prs[step]["pr_id"]
|
| 66 |
+
return self.episode_prs[-1]["pr_id"]
|
| 67 |
+
|
| 68 |
+
def is_done(self, step: int) -> bool:
|
| 69 |
+
"""Check if episode is complete."""
|
| 70 |
+
return step >= self.EPISODE_LENGTH
|
| 71 |
+
|
| 72 |
+
def get_ground_truth(self, step: int) -> Dict:
|
| 73 |
+
"""Get ground truth for grading at given step."""
|
| 74 |
+
template = self.episode_prs[min(step, len(self.episode_prs) - 1)]
|
| 75 |
+
return {
|
| 76 |
+
"pr_id": template["pr_id"],
|
| 77 |
+
"severity": template["ground_truth_severity"],
|
| 78 |
+
"bug_category": template["bug_category"],
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
def get_system_prompt(self) -> str:
|
| 82 |
+
"""Return system prompt for LLM agents on this task."""
|
| 83 |
+
return (
|
| 84 |
+
"You are a senior software engineer. You will receive a pull request.\n"
|
| 85 |
+
'Respond ONLY with this JSON:\n'
|
| 86 |
+
'{"action_type": "label_severity", "severity": "<critical|high|medium|low|none>"}\n'
|
| 87 |
+
'Example: {"action_type": "label_severity", "severity": "high"}\n'
|
| 88 |
+
"No explanation. JSON only."
|
| 89 |
+
)
|
tasks/task_hard.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hard Task — Actionable Feedback Generation
|
| 3 |
+
|
| 4 |
+
Objective: Agent reviews PRs, adds comments, then approves or requests changes.
|
| 5 |
+
Episode length: 3 PRs
|
| 6 |
+
Agent may make up to 5 add_comment actions per PR before approve/request_changes.
|
| 7 |
+
Required actions: add_comment (multiple), then approve or request_changes.
|
| 8 |
+
|
| 9 |
+
This is the most challenging task — requires understanding code semantics,
|
| 10 |
+
identifying bug locations, generating specific feedback, and making
|
| 11 |
+
appropriate review decisions. The five-component grader ensures agents
|
| 12 |
+
can't game the score with superficial comments.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from typing import Dict, List, Optional
|
| 16 |
+
from env.data_generator import DataGenerator, _build_observation
|
| 17 |
+
from env.models import Observation
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class HardTask:
|
| 21 |
+
"""
|
| 22 |
+
Task configuration for feedback generation.
|
| 23 |
+
|
| 24 |
+
Generates episodes of 3 PRs requiring detailed review.
|
| 25 |
+
Each PR allows up to 5 add_comment actions before a final
|
| 26 |
+
approve/request_changes decision.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
TASK_NAME = "hard"
|
| 30 |
+
EPISODE_LENGTH = 3 # number of PRs per episode
|
| 31 |
+
MAX_COMMENTS_PER_PR = 5
|
| 32 |
+
REQUIRED_ACTIONS = {"add_comment", "approve", "request_changes"}
|
| 33 |
+
|
| 34 |
+
def __init__(self, seed: int = 42):
|
| 35 |
+
self.seed = seed
|
| 36 |
+
self.generator = DataGenerator(seed=seed)
|
| 37 |
+
self.episode_prs: List[Dict] = []
|
| 38 |
+
self.current_pr_index: int = 0
|
| 39 |
+
self.comments_on_current_pr: int = 0
|
| 40 |
+
|
| 41 |
+
def reset(self) -> Observation:
|
| 42 |
+
"""Generate a new episode and return first observation."""
|
| 43 |
+
self.episode_prs = self.generator.generate_hard_episode(
|
| 44 |
+
num_prs=self.EPISODE_LENGTH,
|
| 45 |
+
)
|
| 46 |
+
self.current_pr_index = 0
|
| 47 |
+
self.comments_on_current_pr = 0
|
| 48 |
+
return self._get_observation()
|
| 49 |
+
|
| 50 |
+
def get_observation(self, step: int = -1) -> Observation:
|
| 51 |
+
"""Get observation for the current PR being reviewed."""
|
| 52 |
+
return self._get_observation()
|
| 53 |
+
|
| 54 |
+
def _get_observation(self) -> Observation:
|
| 55 |
+
"""Build observation from current PR template."""
|
| 56 |
+
if self.current_pr_index >= len(self.episode_prs):
|
| 57 |
+
idx = len(self.episode_prs) - 1
|
| 58 |
+
else:
|
| 59 |
+
idx = self.current_pr_index
|
| 60 |
+
|
| 61 |
+
template = self.episode_prs[idx]
|
| 62 |
+
remaining_ids = [t["pr_id"] for t in self.episode_prs[idx + 1:]]
|
| 63 |
+
|
| 64 |
+
return _build_observation(
|
| 65 |
+
template=template,
|
| 66 |
+
step_number=self.current_pr_index,
|
| 67 |
+
episode_budget=self.EPISODE_LENGTH - self.current_pr_index,
|
| 68 |
+
review_queue=remaining_ids,
|
| 69 |
+
existing_comments=[
|
| 70 |
+
f"Comment {i+1} on this PR" for i in range(self.comments_on_current_pr)
|
| 71 |
+
] if self.comments_on_current_pr > 0 else [],
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
def process_action(self, action_type: str) -> bool:
|
| 75 |
+
"""
|
| 76 |
+
Process an action and return whether we advance to next PR.
|
| 77 |
+
|
| 78 |
+
add_comment: increments counter, stays on current PR
|
| 79 |
+
approve/request_changes: advances to next PR
|
| 80 |
+
|
| 81 |
+
Returns True if we moved to the next PR.
|
| 82 |
+
"""
|
| 83 |
+
if action_type == "add_comment":
|
| 84 |
+
self.comments_on_current_pr += 1
|
| 85 |
+
# Auto-advance if hit comment limit
|
| 86 |
+
if self.comments_on_current_pr >= self.MAX_COMMENTS_PER_PR:
|
| 87 |
+
return self._advance_pr()
|
| 88 |
+
return False
|
| 89 |
+
elif action_type in ("approve", "request_changes"):
|
| 90 |
+
return self._advance_pr()
|
| 91 |
+
return False
|
| 92 |
+
|
| 93 |
+
def _advance_pr(self) -> bool:
|
| 94 |
+
"""Move to next PR in the episode."""
|
| 95 |
+
self.current_pr_index += 1
|
| 96 |
+
self.comments_on_current_pr = 0
|
| 97 |
+
return True
|
| 98 |
+
|
| 99 |
+
def get_current_pr_id(self) -> str:
|
| 100 |
+
"""Get the PR ID currently being reviewed."""
|
| 101 |
+
idx = min(self.current_pr_index, len(self.episode_prs) - 1)
|
| 102 |
+
return self.episode_prs[idx]["pr_id"]
|
| 103 |
+
|
| 104 |
+
def get_current_template(self) -> Dict:
|
| 105 |
+
"""Get full template for current PR."""
|
| 106 |
+
idx = min(self.current_pr_index, len(self.episode_prs) - 1)
|
| 107 |
+
return self.episode_prs[idx]
|
| 108 |
+
|
| 109 |
+
def is_done(self) -> bool:
|
| 110 |
+
"""Check if episode is complete (all PRs reviewed)."""
|
| 111 |
+
return self.current_pr_index >= self.EPISODE_LENGTH
|
| 112 |
+
|
| 113 |
+
def get_total_steps(self) -> int:
|
| 114 |
+
"""
|
| 115 |
+
Get total steps in this episode.
|
| 116 |
+
|
| 117 |
+
Hard task is variable-length: each PR can have 1-6 actions
|
| 118 |
+
(up to 5 comments + 1 decision). Max steps = 3 * 6 = 18.
|
| 119 |
+
"""
|
| 120 |
+
return self.EPISODE_LENGTH * (self.MAX_COMMENTS_PER_PR + 1)
|
| 121 |
+
|
| 122 |
+
def get_system_prompt(self) -> str:
|
| 123 |
+
"""Return system prompt for LLM agents on this task."""
|
| 124 |
+
return (
|
| 125 |
+
"You are a senior software engineer performing code review.\n"
|
| 126 |
+
"Add review comments, then approve or request changes.\n"
|
| 127 |
+
"For comments respond with:\n"
|
| 128 |
+
'{"action_type": "add_comment", "comment": "<your comment>", '
|
| 129 |
+
'"target_file": "<filename>", "target_line": <line_number>}\n'
|
| 130 |
+
"To finish respond with:\n"
|
| 131 |
+
'{"action_type": "request_changes"} or {"action_type": "approve"}\n'
|
| 132 |
+
"No explanation. JSON only."
|
| 133 |
+
)
|
tasks/task_medium.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Medium Task — Review Queue Prioritization
|
| 3 |
+
|
| 4 |
+
Objective: Agent receives a queue of 5 PRs. Must order by review priority.
|
| 5 |
+
Episode length: 3 queue orderings
|
| 6 |
+
Required action: action_type="prioritize", priority_order=[list of pr_ids]
|
| 7 |
+
|
| 8 |
+
Priority rules (ground truth ordering):
|
| 9 |
+
1. Security PRs (sql_injection, security_vulnerability) always first
|
| 10 |
+
2. By severity: critical > high > medium > low > none
|
| 11 |
+
3. Within same severity: junior authors first (urgency heuristic)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from typing import Dict, List
|
| 15 |
+
from env.data_generator import DataGenerator, _build_observation
|
| 16 |
+
from env.models import Observation
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class MediumTask:
|
| 20 |
+
"""
|
| 21 |
+
Task configuration for queue prioritization.
|
| 22 |
+
|
| 23 |
+
Generates episodes of 3 queue orderings from FIXED_TEST_SUITE.
|
| 24 |
+
Each step presents a queue of 5 PRs; the agent must order them.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
TASK_NAME = "medium"
|
| 28 |
+
EPISODE_LENGTH = 3
|
| 29 |
+
QUEUE_SIZE = 5
|
| 30 |
+
REQUIRED_ACTION = "prioritize"
|
| 31 |
+
|
| 32 |
+
def __init__(self, seed: int = 42):
|
| 33 |
+
self.seed = seed
|
| 34 |
+
self.generator = DataGenerator(seed=seed)
|
| 35 |
+
self.episode_queues: List[List[Dict]] = []
|
| 36 |
+
self.current_step: int = 0
|
| 37 |
+
|
| 38 |
+
def reset(self) -> Observation:
|
| 39 |
+
"""Generate a new episode and return first observation."""
|
| 40 |
+
self.episode_queues = self.generator.generate_medium_episode(
|
| 41 |
+
num_queues=self.EPISODE_LENGTH,
|
| 42 |
+
queue_size=self.QUEUE_SIZE,
|
| 43 |
+
)
|
| 44 |
+
self.current_step = 0
|
| 45 |
+
return self._get_observation(0)
|
| 46 |
+
|
| 47 |
+
def get_observation(self, step: int) -> Observation:
|
| 48 |
+
"""Get observation for a specific step."""
|
| 49 |
+
return self._get_observation(step)
|
| 50 |
+
|
| 51 |
+
def _get_observation(self, step: int) -> Observation:
|
| 52 |
+
"""Build observation from queue at given step."""
|
| 53 |
+
if step >= len(self.episode_queues):
|
| 54 |
+
step = len(self.episode_queues) - 1
|
| 55 |
+
|
| 56 |
+
queue = self.episode_queues[step]
|
| 57 |
+
# Use first PR in queue as the main observation, with queue IDs
|
| 58 |
+
template = queue[0]
|
| 59 |
+
queue_ids = [t["pr_id"] for t in queue]
|
| 60 |
+
|
| 61 |
+
return _build_observation(
|
| 62 |
+
template=template,
|
| 63 |
+
step_number=step,
|
| 64 |
+
episode_budget=self.EPISODE_LENGTH - step,
|
| 65 |
+
review_queue=queue_ids,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
def get_queue_templates(self, step: int) -> List[Dict]:
|
| 69 |
+
"""Get full template dicts for the queue at given step."""
|
| 70 |
+
if step < len(self.episode_queues):
|
| 71 |
+
return self.episode_queues[step]
|
| 72 |
+
return self.episode_queues[-1]
|
| 73 |
+
|
| 74 |
+
def get_ground_truth_order(self, step: int) -> List[str]:
|
| 75 |
+
"""Get ground truth priority ordering for the queue at given step."""
|
| 76 |
+
queue = self.get_queue_templates(step)
|
| 77 |
+
return self.generator.compute_priority_order(queue)
|
| 78 |
+
|
| 79 |
+
def get_current_pr_id(self, step: int) -> str:
|
| 80 |
+
"""Get the representative PR ID for the current step."""
|
| 81 |
+
if step < len(self.episode_queues):
|
| 82 |
+
return self.episode_queues[step][0]["pr_id"]
|
| 83 |
+
return self.episode_queues[-1][0]["pr_id"]
|
| 84 |
+
|
| 85 |
+
def is_done(self, step: int) -> bool:
|
| 86 |
+
"""Check if episode is complete."""
|
| 87 |
+
return step >= self.EPISODE_LENGTH
|
| 88 |
+
|
| 89 |
+
def get_system_prompt(self) -> str:
|
| 90 |
+
"""Return system prompt for LLM agents on this task."""
|
| 91 |
+
return (
|
| 92 |
+
"You are a senior software engineer. You will receive a queue of PRs.\n"
|
| 93 |
+
"Order them by review priority, most urgent first.\n"
|
| 94 |
+
'Respond ONLY with this JSON:\n'
|
| 95 |
+
'{"action_type": "prioritize", "priority_order": ["pr_id_1", "pr_id_2", ...]}\n'
|
| 96 |
+
"No explanation. JSON only."
|
| 97 |
+
)
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Tests package."""
|
tests/test_env.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test suite for CodeReviewEnv.
|
| 3 |
+
|
| 4 |
+
19 tests covering core interface, grader ranges, score variance,
|
| 5 |
+
reproducibility, exploit prevention, and agent baselines.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import random
|
| 9 |
+
import statistics
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from env.base import CodeReviewEnv
|
| 13 |
+
from env.models import Action, Observation, Reward, State
|
| 14 |
+
from env.data_generator import PR_TEMPLATES
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ── Core Interface Tests ──────────────────────────────────────────────────────
|
| 18 |
+
|
| 19 |
+
class TestCoreInterface:
|
| 20 |
+
"""Tests for the basic OpenEnv interface contract."""
|
| 21 |
+
|
| 22 |
+
@pytest.mark.parametrize("task", ["easy", "medium", "hard"])
|
| 23 |
+
def test_reset_returns_observation(self, task):
|
| 24 |
+
"""reset() must return valid Observation for all tasks."""
|
| 25 |
+
env = CodeReviewEnv(task=task, seed=42)
|
| 26 |
+
obs = env.reset()
|
| 27 |
+
assert isinstance(obs, Observation)
|
| 28 |
+
assert obs.pr_id
|
| 29 |
+
assert obs.title
|
| 30 |
+
assert len(obs.files) > 0
|
| 31 |
+
|
| 32 |
+
def test_step_valid_action(self):
|
| 33 |
+
"""step() with valid action returns (Observation, Reward, bool, Dict)."""
|
| 34 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 35 |
+
env.reset()
|
| 36 |
+
action = Action(action_type="label_severity", severity="high")
|
| 37 |
+
result = env.step(action)
|
| 38 |
+
assert len(result) == 4
|
| 39 |
+
obs, reward, done, info = result
|
| 40 |
+
assert isinstance(obs, Observation)
|
| 41 |
+
assert isinstance(reward, Reward)
|
| 42 |
+
assert isinstance(done, bool)
|
| 43 |
+
assert isinstance(info, dict)
|
| 44 |
+
|
| 45 |
+
def test_step_invalid_action_no_crash(self):
|
| 46 |
+
"""Malformed action returns penalty reward, never raises."""
|
| 47 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 48 |
+
env.reset()
|
| 49 |
+
# Wrong action type for easy task
|
| 50 |
+
action = Action(action_type="approve")
|
| 51 |
+
obs, reward, done, info = env.step(action)
|
| 52 |
+
assert isinstance(reward, Reward)
|
| 53 |
+
assert reward.value <= 0 # Should be penalized
|
| 54 |
+
|
| 55 |
+
def test_reward_bounds(self):
|
| 56 |
+
"""reward.value always in [-1.0, 1.0] across many random actions."""
|
| 57 |
+
random.seed(42)
|
| 58 |
+
severities = ["critical", "high", "medium", "low", "none"]
|
| 59 |
+
for _ in range(100):
|
| 60 |
+
env = CodeReviewEnv(task="easy", seed=random.randint(1, 999))
|
| 61 |
+
env.reset()
|
| 62 |
+
sev = random.choice(severities)
|
| 63 |
+
action = Action(action_type="label_severity", severity=sev)
|
| 64 |
+
_, reward, _, _ = env.step(action)
|
| 65 |
+
assert -1.0 <= reward.value <= 1.0, f"Reward {reward.value} out of bounds"
|
| 66 |
+
|
| 67 |
+
def test_episode_terminates(self):
|
| 68 |
+
"""done=True exactly at episode_length steps for easy task."""
|
| 69 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 70 |
+
env.reset()
|
| 71 |
+
for i in range(4):
|
| 72 |
+
action = Action(action_type="label_severity", severity="medium")
|
| 73 |
+
_, _, done, _ = env.step(action)
|
| 74 |
+
assert done is False, f"Episode terminated early at step {i}"
|
| 75 |
+
action = Action(action_type="label_severity", severity="medium")
|
| 76 |
+
_, _, done, _ = env.step(action)
|
| 77 |
+
assert done is True, "Episode should be done after 5 steps"
|
| 78 |
+
|
| 79 |
+
def test_state_trajectory_length(self):
|
| 80 |
+
"""state().trajectory length equals step count."""
|
| 81 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 82 |
+
env.reset()
|
| 83 |
+
for i in range(3):
|
| 84 |
+
action = Action(action_type="label_severity", severity="high")
|
| 85 |
+
env.step(action)
|
| 86 |
+
s = env.state()
|
| 87 |
+
assert isinstance(s, State)
|
| 88 |
+
assert len(s.trajectory) == 3
|
| 89 |
+
|
| 90 |
+
def test_export_trajectory_format(self):
|
| 91 |
+
"""All required keys present in each transition."""
|
| 92 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 93 |
+
env.reset()
|
| 94 |
+
for _ in range(5):
|
| 95 |
+
action = Action(action_type="label_severity", severity="high")
|
| 96 |
+
env.step(action)
|
| 97 |
+
traj = env.export_trajectory()
|
| 98 |
+
assert len(traj) == 5
|
| 99 |
+
required = {"step", "state", "action", "reward", "next_state"}
|
| 100 |
+
for t in traj:
|
| 101 |
+
assert required <= set(t.keys()), f"Missing keys: {required - set(t.keys())}"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ── Grader Range Tests ────────────────────────────────────────────────────────
|
| 105 |
+
|
| 106 |
+
class TestGraderRanges:
|
| 107 |
+
"""Tests that grader scores are within expected bounds."""
|
| 108 |
+
|
| 109 |
+
def test_grader_easy_range(self):
|
| 110 |
+
"""Easy grader scores in [0, 1] for all test PRs."""
|
| 111 |
+
from graders.grader_easy import EasyGrader
|
| 112 |
+
for template in PR_TEMPLATES:
|
| 113 |
+
grader = EasyGrader()
|
| 114 |
+
for sev in ["critical", "high", "medium", "low", "none"]:
|
| 115 |
+
action = Action(action_type="label_severity", severity=sev)
|
| 116 |
+
reward, _ = grader.grade(action, template["pr_id"])
|
| 117 |
+
assert -1.0 <= reward.value <= 1.0
|
| 118 |
+
|
| 119 |
+
def test_grader_medium_range(self):
|
| 120 |
+
"""Medium grader scores in [0, 1] for all test queues."""
|
| 121 |
+
from graders.grader_medium import MediumGrader
|
| 122 |
+
from env.data_generator import DataGenerator
|
| 123 |
+
grader = MediumGrader()
|
| 124 |
+
gen = DataGenerator(seed=42)
|
| 125 |
+
queues = gen.generate_medium_episode(num_queues=3, queue_size=5)
|
| 126 |
+
for queue in queues:
|
| 127 |
+
gt = gen.compute_priority_order(queue)
|
| 128 |
+
action = Action(action_type="prioritize", priority_order=gt)
|
| 129 |
+
reward, _ = grader.grade(action, queue, gt)
|
| 130 |
+
assert 0.0 <= reward.value <= 1.0
|
| 131 |
+
|
| 132 |
+
def test_grader_hard_range(self):
|
| 133 |
+
"""Hard grader scores in [-1, 1] for all test comment sets."""
|
| 134 |
+
from graders.grader_hard import HardGrader
|
| 135 |
+
for template in PR_TEMPLATES[:5]:
|
| 136 |
+
grader = HardGrader()
|
| 137 |
+
pr_id = template["pr_id"]
|
| 138 |
+
if template["bug_lines"]:
|
| 139 |
+
action = Action(
|
| 140 |
+
action_type="add_comment",
|
| 141 |
+
comment="Consider adding a check here",
|
| 142 |
+
target_file=template["filename"],
|
| 143 |
+
target_line=template["bug_lines"][0],
|
| 144 |
+
)
|
| 145 |
+
grader.add_comment(pr_id, action)
|
| 146 |
+
reward, _ = grader.grade_pr(pr_id, "request_changes")
|
| 147 |
+
assert -1.0 <= reward.value <= 1.0
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ── Score Variance Tests ──────────────────────────────────────────────────────
|
| 151 |
+
|
| 152 |
+
class TestScoreVariance:
|
| 153 |
+
"""Tests that scores have meaningful variance (not trivially constant)."""
|
| 154 |
+
|
| 155 |
+
def test_score_variance_easy(self):
|
| 156 |
+
"""Easy task has score std > 0.05 across random actions."""
|
| 157 |
+
from graders.grader_easy import EasyGrader
|
| 158 |
+
random.seed(42)
|
| 159 |
+
scores = []
|
| 160 |
+
for template in PR_TEMPLATES:
|
| 161 |
+
grader = EasyGrader()
|
| 162 |
+
sev = random.choice(["critical", "high", "medium", "low", "none"])
|
| 163 |
+
action = Action(action_type="label_severity", severity=sev)
|
| 164 |
+
reward, _ = grader.grade(action, template["pr_id"])
|
| 165 |
+
scores.append(reward.value)
|
| 166 |
+
assert statistics.stdev(scores) > 0.05
|
| 167 |
+
|
| 168 |
+
def test_score_variance_medium(self):
|
| 169 |
+
"""Medium task has score std > 0.05 across different orderings."""
|
| 170 |
+
from graders.grader_medium import MediumGrader
|
| 171 |
+
from env.data_generator import DataGenerator
|
| 172 |
+
random.seed(42)
|
| 173 |
+
scores = []
|
| 174 |
+
gen = DataGenerator(seed=42)
|
| 175 |
+
queues = gen.generate_medium_episode(num_queues=3, queue_size=5)
|
| 176 |
+
for queue in queues:
|
| 177 |
+
gt = gen.compute_priority_order(queue)
|
| 178 |
+
# Test with various orderings
|
| 179 |
+
for _ in range(5):
|
| 180 |
+
order = list(gt)
|
| 181 |
+
random.shuffle(order)
|
| 182 |
+
grader = MediumGrader()
|
| 183 |
+
action = Action(action_type="prioritize", priority_order=order)
|
| 184 |
+
reward, _ = grader.grade(action, queue, gt)
|
| 185 |
+
scores.append(reward.value)
|
| 186 |
+
assert statistics.stdev(scores) > 0.05
|
| 187 |
+
|
| 188 |
+
def test_score_variance_hard(self):
|
| 189 |
+
"""Hard task has score std > 0.10 across different feedback."""
|
| 190 |
+
from graders.grader_hard import HardGrader
|
| 191 |
+
scores = []
|
| 192 |
+
for template in PR_TEMPLATES[:6]:
|
| 193 |
+
# No comments + approve
|
| 194 |
+
grader = HardGrader()
|
| 195 |
+
reward, _ = grader.grade_pr(template["pr_id"], "approve")
|
| 196 |
+
scores.append(reward.value)
|
| 197 |
+
|
| 198 |
+
# Good comment + request_changes
|
| 199 |
+
grader = HardGrader()
|
| 200 |
+
if template["bug_lines"]:
|
| 201 |
+
action = Action(
|
| 202 |
+
action_type="add_comment",
|
| 203 |
+
comment="Consider adding a null check to prevent crash",
|
| 204 |
+
target_file=template["filename"],
|
| 205 |
+
target_line=template["bug_lines"][0],
|
| 206 |
+
)
|
| 207 |
+
grader.add_comment(template["pr_id"], action)
|
| 208 |
+
reward, _ = grader.grade_pr(template["pr_id"], "request_changes")
|
| 209 |
+
scores.append(reward.value)
|
| 210 |
+
assert statistics.stdev(scores) > 0.10
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ── Reproducibility Tests ─────────────────────────────────────────────────────
|
| 214 |
+
|
| 215 |
+
class TestReproducibility:
|
| 216 |
+
"""Tests that same seed produces identical results."""
|
| 217 |
+
|
| 218 |
+
def test_reproducibility(self):
|
| 219 |
+
"""Two runs with seed=42 produce identical trajectories."""
|
| 220 |
+
traj1 = self._run_episode(42)
|
| 221 |
+
traj2 = self._run_episode(42)
|
| 222 |
+
assert len(traj1) == len(traj2)
|
| 223 |
+
for t1, t2 in zip(traj1, traj2):
|
| 224 |
+
assert t1 == t2, "Trajectories differ with same seed"
|
| 225 |
+
|
| 226 |
+
@staticmethod
|
| 227 |
+
def _run_episode(seed):
|
| 228 |
+
env = CodeReviewEnv(task="easy", seed=seed)
|
| 229 |
+
env.reset()
|
| 230 |
+
rewards = []
|
| 231 |
+
for _ in range(5):
|
| 232 |
+
action = Action(action_type="label_severity", severity="high")
|
| 233 |
+
_, reward, _, _ = env.step(action)
|
| 234 |
+
rewards.append(reward.value)
|
| 235 |
+
return rewards
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ── Exploit Prevention Tests ──────────────────────────────────────────────────
|
| 239 |
+
|
| 240 |
+
class TestExploitPrevention:
|
| 241 |
+
"""Tests that gaming strategies are penalized."""
|
| 242 |
+
|
| 243 |
+
def test_exploit_approve_no_comments(self):
|
| 244 |
+
"""Approving without comments scores 0.0 on hard task."""
|
| 245 |
+
from graders.grader_hard import HardGrader
|
| 246 |
+
grader = HardGrader()
|
| 247 |
+
reward, _ = grader.grade_pr("PR-001", "approve")
|
| 248 |
+
assert reward.value == 0.0
|
| 249 |
+
|
| 250 |
+
def test_exploit_spam_comments(self):
|
| 251 |
+
"""Spamming comments scores lower than targeted comments."""
|
| 252 |
+
from graders.grader_hard import HardGrader
|
| 253 |
+
template = PR_TEMPLATES[0] # Java null pointer
|
| 254 |
+
pr_id = template["pr_id"]
|
| 255 |
+
|
| 256 |
+
# Targeted: 3 relevant comments
|
| 257 |
+
grader_targeted = HardGrader()
|
| 258 |
+
for bl in template["bug_lines"][:3]:
|
| 259 |
+
action = Action(
|
| 260 |
+
action_type="add_comment",
|
| 261 |
+
comment="Add null check guard here to prevent NullPointerException",
|
| 262 |
+
target_file=template["filename"],
|
| 263 |
+
target_line=bl,
|
| 264 |
+
)
|
| 265 |
+
grader_targeted.add_comment(pr_id, action)
|
| 266 |
+
reward_targeted, _ = grader_targeted.grade_pr(pr_id, "request_changes")
|
| 267 |
+
|
| 268 |
+
# Spam: 15 irrelevant comments
|
| 269 |
+
grader_spam = HardGrader()
|
| 270 |
+
for i in range(15):
|
| 271 |
+
action = Action(
|
| 272 |
+
action_type="add_comment",
|
| 273 |
+
comment=f"Comment {i}",
|
| 274 |
+
target_file=template["filename"],
|
| 275 |
+
target_line=i + 100,
|
| 276 |
+
)
|
| 277 |
+
grader_spam.add_comment(pr_id, action)
|
| 278 |
+
reward_spam, _ = grader_spam.grade_pr(pr_id, "request_changes")
|
| 279 |
+
|
| 280 |
+
assert reward_targeted.value > reward_spam.value, (
|
| 281 |
+
f"Targeted ({reward_targeted.value:.3f}) should beat spam ({reward_spam.value:.3f})"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
def test_exploit_random_severity(self):
|
| 285 |
+
"""Random agent scores < 0.5 on easy task."""
|
| 286 |
+
random.seed(42)
|
| 287 |
+
scores = []
|
| 288 |
+
for template in PR_TEMPLATES:
|
| 289 |
+
from graders.grader_easy import EasyGrader
|
| 290 |
+
grader = EasyGrader()
|
| 291 |
+
sev = random.choice(["critical", "high", "medium", "low", "none"])
|
| 292 |
+
action = Action(action_type="label_severity", severity=sev)
|
| 293 |
+
reward, _ = grader.grade(action, template["pr_id"])
|
| 294 |
+
scores.append(reward.value)
|
| 295 |
+
assert statistics.mean(scores) < 0.5
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ── Agent Baseline Tests ─────────────────────────────────────────────────────
|
| 299 |
+
|
| 300 |
+
class TestAgentBaselines:
|
| 301 |
+
"""Tests that floor and ceiling agents perform as expected."""
|
| 302 |
+
|
| 303 |
+
def test_perfect_agent_easy(self):
|
| 304 |
+
"""Perfect agent scores > 0.85 on easy task."""
|
| 305 |
+
from graders.grader_easy import EasyGrader
|
| 306 |
+
scores = []
|
| 307 |
+
for template in PR_TEMPLATES:
|
| 308 |
+
grader = EasyGrader()
|
| 309 |
+
action = Action(
|
| 310 |
+
action_type="label_severity",
|
| 311 |
+
severity=template["ground_truth_severity"],
|
| 312 |
+
)
|
| 313 |
+
reward, _ = grader.grade(action, template["pr_id"])
|
| 314 |
+
scores.append(reward.value)
|
| 315 |
+
assert statistics.mean(scores) > 0.85
|
| 316 |
+
|
| 317 |
+
def test_perfect_agent_hard(self):
|
| 318 |
+
"""Perfect agent scores > 0.75 on hard task."""
|
| 319 |
+
from graders.grader_hard import HardGrader
|
| 320 |
+
scores = []
|
| 321 |
+
for template in PR_TEMPLATES[:3]:
|
| 322 |
+
grader = HardGrader()
|
| 323 |
+
pr_id = template["pr_id"]
|
| 324 |
+
bug_cat = template["bug_category"]
|
| 325 |
+
sev = template["ground_truth_severity"]
|
| 326 |
+
|
| 327 |
+
# Add targeted, specific, actionable comment for each bug line
|
| 328 |
+
from env.data_generator import BUG_KEYWORDS
|
| 329 |
+
keywords = BUG_KEYWORDS.get(bug_cat, [])
|
| 330 |
+
keyword = keywords[0] if keywords else "issue"
|
| 331 |
+
|
| 332 |
+
for bl in template["bug_lines"]:
|
| 333 |
+
action = Action(
|
| 334 |
+
action_type="add_comment",
|
| 335 |
+
comment=f"Consider adding a {keyword} check here. You should use proper handling to avoid this issue.",
|
| 336 |
+
target_file=template["filename"],
|
| 337 |
+
target_line=bl,
|
| 338 |
+
)
|
| 339 |
+
grader.add_comment(pr_id, action)
|
| 340 |
+
|
| 341 |
+
decision = "request_changes" if sev != "none" else "approve"
|
| 342 |
+
reward, _ = grader.grade_pr(pr_id, decision)
|
| 343 |
+
scores.append(reward.value)
|
| 344 |
+
|
| 345 |
+
assert statistics.mean(scores) > 0.75, f"Perfect agent scored {statistics.mean(scores):.3f}"
|
validate.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
OpenEnv Spec Compliance Validator for CodeReviewEnv
|
| 4 |
+
|
| 5 |
+
Runs all spec compliance checks. Exit 0 if all pass, exit 1 if any fail.
|
| 6 |
+
Includes both standard OpenEnv checks and research-grade statistical validity.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import random
|
| 13 |
+
import statistics
|
| 14 |
+
|
| 15 |
+
import yaml
|
| 16 |
+
|
| 17 |
+
from env.base import CodeReviewEnv
|
| 18 |
+
from env.models import Action, Observation, Reward, State
|
| 19 |
+
from env.data_generator import PR_TEMPLATES
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def check(name: str, condition: bool, reason: str = "") -> bool:
|
| 23 |
+
"""Print PASS or FAIL with reason."""
|
| 24 |
+
status = "PASS" if condition else "FAIL"
|
| 25 |
+
msg = f" [{status}] {name}"
|
| 26 |
+
if not condition and reason:
|
| 27 |
+
msg += f" — {reason}"
|
| 28 |
+
print(msg)
|
| 29 |
+
return condition
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def validate():
|
| 33 |
+
"""Run all validation checks."""
|
| 34 |
+
print("=" * 60)
|
| 35 |
+
print("CodeReviewEnv — OpenEnv Compliance Validation")
|
| 36 |
+
print("=" * 60)
|
| 37 |
+
results = []
|
| 38 |
+
|
| 39 |
+
# ── 1. reset() returns valid Observation ────────────────────────
|
| 40 |
+
print("\n--- Core Interface ---")
|
| 41 |
+
for task in ["easy", "medium", "hard"]:
|
| 42 |
+
try:
|
| 43 |
+
env = CodeReviewEnv(task=task, seed=42)
|
| 44 |
+
obs = env.reset()
|
| 45 |
+
results.append(check(
|
| 46 |
+
f"reset() returns Observation ({task})",
|
| 47 |
+
isinstance(obs, Observation),
|
| 48 |
+
))
|
| 49 |
+
except Exception as e:
|
| 50 |
+
results.append(check(f"reset() returns Observation ({task})", False, str(e)))
|
| 51 |
+
|
| 52 |
+
# ── 2. step() with valid action returns correct tuple ───────────
|
| 53 |
+
try:
|
| 54 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 55 |
+
env.reset()
|
| 56 |
+
action = Action(action_type="label_severity", severity="high")
|
| 57 |
+
result = env.step(action)
|
| 58 |
+
results.append(check(
|
| 59 |
+
"step() returns (Observation, Reward, bool, Dict)",
|
| 60 |
+
len(result) == 4
|
| 61 |
+
and isinstance(result[0], Observation)
|
| 62 |
+
and isinstance(result[1], Reward)
|
| 63 |
+
and isinstance(result[2], bool)
|
| 64 |
+
and isinstance(result[3], dict),
|
| 65 |
+
))
|
| 66 |
+
except Exception as e:
|
| 67 |
+
results.append(check("step() returns correct tuple", False, str(e)))
|
| 68 |
+
|
| 69 |
+
# ── 3. step() with invalid action doesn't raise ─────────────────
|
| 70 |
+
try:
|
| 71 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 72 |
+
env.reset()
|
| 73 |
+
# Create a technically valid Action but with wrong type for task
|
| 74 |
+
action = Action(action_type="approve")
|
| 75 |
+
obs, reward, done, info = env.step(action)
|
| 76 |
+
results.append(check(
|
| 77 |
+
"step() with wrong action type doesn't crash",
|
| 78 |
+
isinstance(reward, Reward) and reward.value <= 0,
|
| 79 |
+
))
|
| 80 |
+
except Exception as e:
|
| 81 |
+
results.append(check("step() with invalid action doesn't crash", False, str(e)))
|
| 82 |
+
|
| 83 |
+
# ── 4. reward.value always in [-1.0, 1.0] ──────────────────────
|
| 84 |
+
try:
|
| 85 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 86 |
+
env.reset()
|
| 87 |
+
all_in_range = True
|
| 88 |
+
severities = ["critical", "high", "medium", "low", "none"]
|
| 89 |
+
for sev in severities * 20:
|
| 90 |
+
action = Action(action_type="label_severity", severity=sev)
|
| 91 |
+
env2 = CodeReviewEnv(task="easy", seed=random.randint(1, 1000))
|
| 92 |
+
env2.reset()
|
| 93 |
+
_, reward, _, _ = env2.step(action)
|
| 94 |
+
if reward.value < -1.0 or reward.value > 1.0:
|
| 95 |
+
all_in_range = False
|
| 96 |
+
break
|
| 97 |
+
results.append(check(
|
| 98 |
+
"reward.value always in [-1.0, 1.0]",
|
| 99 |
+
all_in_range,
|
| 100 |
+
))
|
| 101 |
+
except Exception as e:
|
| 102 |
+
results.append(check("reward bounds", False, str(e)))
|
| 103 |
+
|
| 104 |
+
# ── 5. done=True after episode_length steps ─────────────────────
|
| 105 |
+
try:
|
| 106 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 107 |
+
env.reset()
|
| 108 |
+
done = False
|
| 109 |
+
for i in range(5):
|
| 110 |
+
action = Action(action_type="label_severity", severity="medium")
|
| 111 |
+
_, _, done, _ = env.step(action)
|
| 112 |
+
results.append(check(
|
| 113 |
+
"done=True after episode_length steps (easy=5)",
|
| 114 |
+
done is True,
|
| 115 |
+
))
|
| 116 |
+
except Exception as e:
|
| 117 |
+
results.append(check("episode terminates", False, str(e)))
|
| 118 |
+
|
| 119 |
+
# ── 6. state() returns State with correct trajectory length ─────
|
| 120 |
+
try:
|
| 121 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 122 |
+
env.reset()
|
| 123 |
+
for i in range(3):
|
| 124 |
+
action = Action(action_type="label_severity", severity="high")
|
| 125 |
+
env.step(action)
|
| 126 |
+
s = env.state()
|
| 127 |
+
results.append(check(
|
| 128 |
+
"state() trajectory length matches step count",
|
| 129 |
+
isinstance(s, State) and len(s.trajectory) == 3,
|
| 130 |
+
f"Expected 3, got {len(s.trajectory) if isinstance(s, State) else 'N/A'}",
|
| 131 |
+
))
|
| 132 |
+
except Exception as e:
|
| 133 |
+
results.append(check("state() correct", False, str(e)))
|
| 134 |
+
|
| 135 |
+
# ── 7. export_trajectory() format ───────────────────────────────
|
| 136 |
+
try:
|
| 137 |
+
env = CodeReviewEnv(task="easy", seed=42)
|
| 138 |
+
env.reset()
|
| 139 |
+
for i in range(5):
|
| 140 |
+
action = Action(action_type="label_severity", severity="high")
|
| 141 |
+
env.step(action)
|
| 142 |
+
traj = env.export_trajectory()
|
| 143 |
+
required_keys = {"step", "state", "action", "reward", "next_state"}
|
| 144 |
+
has_keys = all(required_keys <= set(t.keys()) for t in traj)
|
| 145 |
+
results.append(check(
|
| 146 |
+
"export_trajectory() has required keys",
|
| 147 |
+
len(traj) > 0 and has_keys,
|
| 148 |
+
))
|
| 149 |
+
except Exception as e:
|
| 150 |
+
results.append(check("export_trajectory() format", False, str(e)))
|
| 151 |
+
|
| 152 |
+
# ── 8-10. Grader score ranges ───────────────────────────────────
|
| 153 |
+
print("\n--- Grader Validation ---")
|
| 154 |
+
|
| 155 |
+
# Easy grader
|
| 156 |
+
try:
|
| 157 |
+
from graders.grader_easy import EasyGrader
|
| 158 |
+
grader = EasyGrader()
|
| 159 |
+
all_valid = True
|
| 160 |
+
for template in PR_TEMPLATES:
|
| 161 |
+
grader.reset()
|
| 162 |
+
for sev in ["critical", "high", "medium", "low", "none"]:
|
| 163 |
+
action = Action(action_type="label_severity", severity=sev)
|
| 164 |
+
reward, _ = grader.grade(action, template["pr_id"])
|
| 165 |
+
if reward.value < -1.0 or reward.value > 1.0:
|
| 166 |
+
all_valid = False
|
| 167 |
+
results.append(check("grader_easy scores in [-1, 1]", all_valid))
|
| 168 |
+
except Exception as e:
|
| 169 |
+
results.append(check("grader_easy range", False, str(e)))
|
| 170 |
+
|
| 171 |
+
# Medium grader
|
| 172 |
+
try:
|
| 173 |
+
from graders.grader_medium import MediumGrader
|
| 174 |
+
from env.data_generator import DataGenerator
|
| 175 |
+
grader = MediumGrader()
|
| 176 |
+
gen = DataGenerator(seed=42)
|
| 177 |
+
queues = gen.generate_medium_episode(num_queues=3, queue_size=5)
|
| 178 |
+
all_valid = True
|
| 179 |
+
for queue in queues:
|
| 180 |
+
gt_order = gen.compute_priority_order(queue)
|
| 181 |
+
# Test with correct order
|
| 182 |
+
action = Action(action_type="prioritize", priority_order=gt_order)
|
| 183 |
+
reward, _ = grader.grade(action, queue, gt_order)
|
| 184 |
+
if reward.value < 0.0 or reward.value > 1.0:
|
| 185 |
+
all_valid = False
|
| 186 |
+
# Test with reversed order
|
| 187 |
+
action = Action(action_type="prioritize", priority_order=list(reversed(gt_order)))
|
| 188 |
+
reward, _ = grader.grade(action, queue, gt_order)
|
| 189 |
+
if reward.value < 0.0 or reward.value > 1.0:
|
| 190 |
+
all_valid = False
|
| 191 |
+
results.append(check("grader_medium scores in [0, 1]", all_valid))
|
| 192 |
+
except Exception as e:
|
| 193 |
+
results.append(check("grader_medium range", False, str(e)))
|
| 194 |
+
|
| 195 |
+
# Hard grader
|
| 196 |
+
try:
|
| 197 |
+
from graders.grader_hard import HardGrader
|
| 198 |
+
grader = HardGrader()
|
| 199 |
+
all_valid = True
|
| 200 |
+
for template in PR_TEMPLATES[:5]:
|
| 201 |
+
grader.reset()
|
| 202 |
+
pr_id = template["pr_id"]
|
| 203 |
+
bug_lines = template["bug_lines"]
|
| 204 |
+
if bug_lines:
|
| 205 |
+
comment_action = Action(
|
| 206 |
+
action_type="add_comment",
|
| 207 |
+
comment="Consider adding null check here to prevent crash",
|
| 208 |
+
target_file=template["filename"],
|
| 209 |
+
target_line=bug_lines[0],
|
| 210 |
+
)
|
| 211 |
+
grader.add_comment(pr_id, comment_action)
|
| 212 |
+
reward, _ = grader.grade_pr(pr_id, "request_changes")
|
| 213 |
+
if reward.value < -1.0 or reward.value > 1.0:
|
| 214 |
+
all_valid = False
|
| 215 |
+
results.append(check("grader_hard scores in [-1, 1]", all_valid))
|
| 216 |
+
except Exception as e:
|
| 217 |
+
results.append(check("grader_hard range", False, str(e)))
|
| 218 |
+
|
| 219 |
+
# ── 11. Score variance check ────────────────────────────────────
|
| 220 |
+
print("\n--- Statistical Validity ---")
|
| 221 |
+
try:
|
| 222 |
+
scores = []
|
| 223 |
+
for template in PR_TEMPLATES:
|
| 224 |
+
grader = EasyGrader()
|
| 225 |
+
# Test with random severity
|
| 226 |
+
sev = random.choice(["critical", "high", "medium", "low", "none"])
|
| 227 |
+
action = Action(action_type="label_severity", severity=sev)
|
| 228 |
+
reward, _ = grader.grade(action, template["pr_id"])
|
| 229 |
+
scores.append(reward.value)
|
| 230 |
+
std = statistics.stdev(scores)
|
| 231 |
+
results.append(check(
|
| 232 |
+
f"Score variance: std={std:.3f} > 0.05",
|
| 233 |
+
std > 0.05,
|
| 234 |
+
))
|
| 235 |
+
except Exception as e:
|
| 236 |
+
results.append(check("score variance", False, str(e)))
|
| 237 |
+
|
| 238 |
+
# ── 12. Random agent < perfect agent ────────────────────────────
|
| 239 |
+
try:
|
| 240 |
+
# Perfect agent
|
| 241 |
+
perfect_scores = []
|
| 242 |
+
for template in PR_TEMPLATES:
|
| 243 |
+
grader = EasyGrader()
|
| 244 |
+
action = Action(action_type="label_severity", severity=template["ground_truth_severity"])
|
| 245 |
+
reward, _ = grader.grade(action, template["pr_id"])
|
| 246 |
+
perfect_scores.append(reward.value)
|
| 247 |
+
perfect_mean = statistics.mean(perfect_scores)
|
| 248 |
+
|
| 249 |
+
# Random agent
|
| 250 |
+
random.seed(42)
|
| 251 |
+
random_scores = []
|
| 252 |
+
for template in PR_TEMPLATES:
|
| 253 |
+
grader = EasyGrader()
|
| 254 |
+
sev = random.choice(["critical", "high", "medium", "low", "none"])
|
| 255 |
+
action = Action(action_type="label_severity", severity=sev)
|
| 256 |
+
reward, _ = grader.grade(action, template["pr_id"])
|
| 257 |
+
random_scores.append(reward.value)
|
| 258 |
+
random_mean = statistics.mean(random_scores)
|
| 259 |
+
|
| 260 |
+
gap = perfect_mean - random_mean
|
| 261 |
+
results.append(check(
|
| 262 |
+
f"Perfect ({perfect_mean:.2f}) > Random ({random_mean:.2f}) by {gap:.2f} (>0.3)",
|
| 263 |
+
gap > 0.3,
|
| 264 |
+
))
|
| 265 |
+
except Exception as e:
|
| 266 |
+
results.append(check("perfect vs random gap", False, str(e)))
|
| 267 |
+
|
| 268 |
+
# ── 13. Reproducibility ─────────────────────────────────────────
|
| 269 |
+
try:
|
| 270 |
+
env1 = CodeReviewEnv(task="easy", seed=42)
|
| 271 |
+
obs1 = env1.reset()
|
| 272 |
+
traj1 = []
|
| 273 |
+
for _ in range(5):
|
| 274 |
+
action = Action(action_type="label_severity", severity="high")
|
| 275 |
+
_, reward, _, _ = env1.step(action)
|
| 276 |
+
traj1.append(reward.value)
|
| 277 |
+
|
| 278 |
+
env2 = CodeReviewEnv(task="easy", seed=42)
|
| 279 |
+
obs2 = env2.reset()
|
| 280 |
+
traj2 = []
|
| 281 |
+
for _ in range(5):
|
| 282 |
+
action = Action(action_type="label_severity", severity="high")
|
| 283 |
+
_, reward, _, _ = env2.step(action)
|
| 284 |
+
traj2.append(reward.value)
|
| 285 |
+
|
| 286 |
+
results.append(check(
|
| 287 |
+
"seed=42 reproducibility",
|
| 288 |
+
obs1.pr_id == obs2.pr_id and traj1 == traj2,
|
| 289 |
+
))
|
| 290 |
+
except Exception as e:
|
| 291 |
+
results.append(check("reproducibility", False, str(e)))
|
| 292 |
+
|
| 293 |
+
# ── 14. openenv.yaml valid ──────────────────────────────────────
|
| 294 |
+
print("\n--- Packaging ---")
|
| 295 |
+
try:
|
| 296 |
+
with open("openenv.yaml", "r") as f:
|
| 297 |
+
config = yaml.safe_load(f)
|
| 298 |
+
required = ["name", "version", "description", "tasks"]
|
| 299 |
+
has_all = all(k in config for k in required)
|
| 300 |
+
results.append(check(
|
| 301 |
+
"openenv.yaml is valid with required fields",
|
| 302 |
+
has_all,
|
| 303 |
+
))
|
| 304 |
+
except Exception as e:
|
| 305 |
+
results.append(check("openenv.yaml", False, str(e)))
|
| 306 |
+
|
| 307 |
+
# ── 15. Dockerfile exists ───────────────────────────────────────
|
| 308 |
+
results.append(check(
|
| 309 |
+
"Dockerfile exists",
|
| 310 |
+
os.path.exists("Dockerfile"),
|
| 311 |
+
))
|
| 312 |
+
|
| 313 |
+
# ── Summary ─────────────────────────────────────────────────────
|
| 314 |
+
print("\n" + "=" * 60)
|
| 315 |
+
passed = sum(results)
|
| 316 |
+
total = len(results)
|
| 317 |
+
print(f"Results: {passed}/{total} checks passed")
|
| 318 |
+
|
| 319 |
+
if passed == total:
|
| 320 |
+
print("✅ ALL CHECKS PASSED — OpenEnv compliant")
|
| 321 |
+
return 0
|
| 322 |
+
else:
|
| 323 |
+
print(f"❌ {total - passed} checks FAILED")
|
| 324 |
+
return 1
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
if __name__ == "__main__":
|
| 328 |
+
sys.exit(validate())
|
world_model/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""World model package for semantic transition model training."""
|
world_model/scaffold.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Semantic World Model Training Scaffold
|
| 3 |
+
|
| 4 |
+
This module provides the training infrastructure for learning a
|
| 5 |
+
semantic transition model from CodeReviewEnv trajectories.
|
| 6 |
+
|
| 7 |
+
This is the research contribution Layer 3:
|
| 8 |
+
Layer 1: Environment (CodeReviewEnv) — this repo
|
| 9 |
+
Layer 2: Trajectory dataset (export_trajectory())
|
| 10 |
+
Layer 3: Semantic world model (this scaffold)
|
| 11 |
+
Layer 4: Planning with learned model (Dyna-Q over language)
|
| 12 |
+
Layer 5: Paper — "Model-Based RL over Semantic Environments"
|
| 13 |
+
|
| 14 |
+
The scaffold intentionally leaves the model architecture open
|
| 15 |
+
for researchers to plug in their own encoder + transition model.
|
| 16 |
+
|
| 17 |
+
Dependencies: torch, sentence-transformers (optional, install via
|
| 18 |
+
requirements-research.txt). The scaffold is importable without these
|
| 19 |
+
but will raise ImportError when training is attempted.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
from typing import Dict, List, Tuple, Optional, Callable, Any
|
| 25 |
+
|
| 26 |
+
from env.data_generator import SEVERITY_ORDER
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# Action type one-hot encoding
|
| 30 |
+
ACTION_TYPES = ["label_severity", "prioritize", "add_comment", "approve", "request_changes"]
|
| 31 |
+
ACTION_TYPE_DIM = len(ACTION_TYPES)
|
| 32 |
+
SEVERITY_DIM = len(SEVERITY_ORDER)
|
| 33 |
+
ACTION_VECTOR_DIM = ACTION_TYPE_DIM + SEVERITY_DIM # 10-dimensional
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class SemanticTransitionDataset:
|
| 37 |
+
"""
|
| 38 |
+
Dataset wrapping JSONL trajectory files for world model training.
|
| 39 |
+
|
| 40 |
+
Each item: (state_text, action_vector, next_state_text, reward)
|
| 41 |
+
|
| 42 |
+
This is the bridge between CodeReviewEnv trajectories and
|
| 43 |
+
learnable transition models. The encoder argument allows
|
| 44 |
+
researchers to plug in their own state representation.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
def __init__(self, trajectory_dir: str, encoder: Optional[Callable] = None):
|
| 48 |
+
"""
|
| 49 |
+
Args:
|
| 50 |
+
trajectory_dir: path to trajectories/ directory
|
| 51 |
+
encoder: callable that maps observation dict → vector.
|
| 52 |
+
Default: None (returns raw text for custom encoding)
|
| 53 |
+
"""
|
| 54 |
+
self.trajectory_dir = trajectory_dir
|
| 55 |
+
self.encoder = encoder
|
| 56 |
+
self.transitions: List[Dict] = []
|
| 57 |
+
self._load()
|
| 58 |
+
|
| 59 |
+
def _load(self) -> None:
|
| 60 |
+
"""Load all JSONL trajectory files."""
|
| 61 |
+
if not os.path.exists(self.trajectory_dir):
|
| 62 |
+
return
|
| 63 |
+
|
| 64 |
+
for filename in sorted(os.listdir(self.trajectory_dir)):
|
| 65 |
+
if filename.endswith(".jsonl"):
|
| 66 |
+
filepath = os.path.join(self.trajectory_dir, filename)
|
| 67 |
+
with open(filepath, "r") as f:
|
| 68 |
+
for line in f:
|
| 69 |
+
line = line.strip()
|
| 70 |
+
if line:
|
| 71 |
+
self.transitions.append(json.loads(line))
|
| 72 |
+
|
| 73 |
+
def __len__(self) -> int:
|
| 74 |
+
return len(self.transitions)
|
| 75 |
+
|
| 76 |
+
def __getitem__(self, idx: int) -> Tuple:
|
| 77 |
+
"""
|
| 78 |
+
Returns (state, action, next_state, reward) tuple.
|
| 79 |
+
|
| 80 |
+
If encoder is provided, states are encoded vectors.
|
| 81 |
+
Otherwise, states are text strings from state_to_text().
|
| 82 |
+
"""
|
| 83 |
+
transition = self.transitions[idx]
|
| 84 |
+
|
| 85 |
+
state = transition.get("state", {})
|
| 86 |
+
action = transition.get("action", {})
|
| 87 |
+
next_state = transition.get("next_state", {})
|
| 88 |
+
reward = transition.get("reward", {}).get("value", 0.0)
|
| 89 |
+
|
| 90 |
+
state_repr = self.state_to_text(state)
|
| 91 |
+
next_state_repr = self.state_to_text(next_state)
|
| 92 |
+
|
| 93 |
+
if self.encoder:
|
| 94 |
+
state_repr = self.encoder(state_repr)
|
| 95 |
+
next_state_repr = self.encoder(next_state_repr)
|
| 96 |
+
|
| 97 |
+
action_vec = self.action_to_vector(action)
|
| 98 |
+
|
| 99 |
+
return state_repr, action_vec, next_state_repr, reward
|
| 100 |
+
|
| 101 |
+
def state_to_text(self, observation: Dict) -> str:
|
| 102 |
+
"""
|
| 103 |
+
Convert observation dict to flat text for LLM encoding.
|
| 104 |
+
|
| 105 |
+
Format: "PR: {title}. Author: {experience}. Files: {filenames}.
|
| 106 |
+
Description: {description}. Queue: {queue_length} PRs pending."
|
| 107 |
+
|
| 108 |
+
This text representation preserves semantic content while being
|
| 109 |
+
suitable for sentence-transformer encoding.
|
| 110 |
+
"""
|
| 111 |
+
title = observation.get("title", "Unknown PR")
|
| 112 |
+
experience = observation.get("author_experience", "unknown")
|
| 113 |
+
description = observation.get("description", "")
|
| 114 |
+
files = observation.get("files", [])
|
| 115 |
+
queue = observation.get("review_queue", [])
|
| 116 |
+
|
| 117 |
+
filenames = ", ".join(f.get("filename", "?") if isinstance(f, dict) else str(f) for f in files)
|
| 118 |
+
|
| 119 |
+
return (
|
| 120 |
+
f"PR: {title}. Author: {experience}. "
|
| 121 |
+
f"Files: {filenames}. "
|
| 122 |
+
f"Description: {description}. "
|
| 123 |
+
f"Queue: {len(queue)} PRs pending."
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
@staticmethod
|
| 127 |
+
def action_to_vector(action: Dict) -> List[float]:
|
| 128 |
+
"""
|
| 129 |
+
One-hot encode action_type + severity into fixed-length vector.
|
| 130 |
+
|
| 131 |
+
Vector layout: [action_type_one_hot (5)] + [severity_one_hot (5)]
|
| 132 |
+
Total dimension: 10
|
| 133 |
+
|
| 134 |
+
This enables the transition model to condition on action
|
| 135 |
+
numerically while preserving the categorical structure.
|
| 136 |
+
"""
|
| 137 |
+
vec = [0.0] * ACTION_VECTOR_DIM
|
| 138 |
+
|
| 139 |
+
# Action type one-hot
|
| 140 |
+
action_type = action.get("action_type", "")
|
| 141 |
+
if action_type in ACTION_TYPES:
|
| 142 |
+
vec[ACTION_TYPES.index(action_type)] = 1.0
|
| 143 |
+
|
| 144 |
+
# Severity one-hot (if applicable)
|
| 145 |
+
severity = action.get("severity", None)
|
| 146 |
+
if severity and severity in SEVERITY_ORDER:
|
| 147 |
+
vec[ACTION_TYPE_DIM + SEVERITY_ORDER.index(severity)] = 1.0
|
| 148 |
+
|
| 149 |
+
return vec
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class WorldModelTrainer:
|
| 153 |
+
"""
|
| 154 |
+
Training loop scaffold for semantic transition model.
|
| 155 |
+
|
| 156 |
+
Plug in your own model — this handles data loading, train/val split,
|
| 157 |
+
and evaluation. The model must have the signature:
|
| 158 |
+
model(state_vec, action_vec) → (next_state_vec, reward_pred)
|
| 159 |
+
|
| 160 |
+
Usage:
|
| 161 |
+
dataset = SemanticTransitionDataset("trajectories/")
|
| 162 |
+
model = YourTransitionModel()
|
| 163 |
+
trainer = WorldModelTrainer(dataset, model)
|
| 164 |
+
results = trainer.train(epochs=10)
|
| 165 |
+
"""
|
| 166 |
+
|
| 167 |
+
def __init__(self, dataset: SemanticTransitionDataset, model: Any = None):
|
| 168 |
+
"""
|
| 169 |
+
Args:
|
| 170 |
+
model: any callable with signature
|
| 171 |
+
model(state_vec, action_vec) → (next_state_vec, reward_pred)
|
| 172 |
+
If None, uses a simple dummy model for testing.
|
| 173 |
+
"""
|
| 174 |
+
self.dataset = dataset
|
| 175 |
+
self.model = model
|
| 176 |
+
|
| 177 |
+
def train(self, epochs: int = 10, lr: float = 1e-4) -> Dict:
|
| 178 |
+
"""
|
| 179 |
+
Standard training loop with MSE loss.
|
| 180 |
+
|
| 181 |
+
Requires torch. Install via: pip install -r requirements-research.txt
|
| 182 |
+
|
| 183 |
+
Returns: {"train_loss": [...], "val_loss": [...], "reward_mse": float}
|
| 184 |
+
"""
|
| 185 |
+
try:
|
| 186 |
+
import torch
|
| 187 |
+
import torch.nn as nn
|
| 188 |
+
from torch.utils.data import DataLoader, random_split
|
| 189 |
+
except ImportError:
|
| 190 |
+
return {
|
| 191 |
+
"error": "torch not installed. Run: pip install -r requirements-research.txt",
|
| 192 |
+
"train_loss": [],
|
| 193 |
+
"val_loss": [],
|
| 194 |
+
"reward_mse": -1.0,
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
if len(self.dataset) == 0:
|
| 198 |
+
return {
|
| 199 |
+
"error": "No trajectory data. Run episodes first.",
|
| 200 |
+
"train_loss": [],
|
| 201 |
+
"val_loss": [],
|
| 202 |
+
"reward_mse": -1.0,
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
# Simple train/val split 80/20
|
| 206 |
+
n = len(self.dataset)
|
| 207 |
+
n_train = int(0.8 * n)
|
| 208 |
+
n_val = n - n_train
|
| 209 |
+
|
| 210 |
+
train_losses = []
|
| 211 |
+
val_losses = []
|
| 212 |
+
|
| 213 |
+
# Simplified training loop without DataLoader for compatibility
|
| 214 |
+
for epoch in range(epochs):
|
| 215 |
+
epoch_loss = 0.0
|
| 216 |
+
for i in range(min(n_train, n)):
|
| 217 |
+
state, action, next_state, reward = self.dataset[i]
|
| 218 |
+
# If model is provided, use it; otherwise track dummy loss
|
| 219 |
+
if self.model:
|
| 220 |
+
try:
|
| 221 |
+
pred_state, pred_reward = self.model(state, action)
|
| 222 |
+
# Compute simple MSE on reward
|
| 223 |
+
loss = (pred_reward - reward) ** 2
|
| 224 |
+
epoch_loss += loss
|
| 225 |
+
except Exception:
|
| 226 |
+
epoch_loss += 0.0
|
| 227 |
+
else:
|
| 228 |
+
epoch_loss += reward ** 2 # Dummy baseline
|
| 229 |
+
|
| 230 |
+
avg_loss = epoch_loss / max(1, min(n_train, n))
|
| 231 |
+
train_losses.append(avg_loss)
|
| 232 |
+
|
| 233 |
+
# Validation
|
| 234 |
+
val_loss = 0.0
|
| 235 |
+
for i in range(n_train, n):
|
| 236 |
+
state, action, next_state, reward = self.dataset[i]
|
| 237 |
+
val_loss += reward ** 2
|
| 238 |
+
val_losses.append(val_loss / max(1, n_val))
|
| 239 |
+
|
| 240 |
+
return {
|
| 241 |
+
"train_loss": train_losses,
|
| 242 |
+
"val_loss": val_losses,
|
| 243 |
+
"reward_mse": val_losses[-1] if val_losses else -1.0,
|
| 244 |
+
"epochs": epochs,
|
| 245 |
+
"n_train": n_train,
|
| 246 |
+
"n_val": n_val,
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
def evaluate_planning(self, env: Any, horizon: int = 3) -> Dict:
|
| 250 |
+
"""
|
| 251 |
+
Test model-based planning.
|
| 252 |
+
|
| 253 |
+
1. From current state, imagine H-step rollouts using learned model
|
| 254 |
+
2. Pick best action sequence
|
| 255 |
+
3. Execute in real env
|
| 256 |
+
4. Compare imagined vs real reward
|
| 257 |
+
|
| 258 |
+
Returns:
|
| 259 |
+
imagination_error: mean |predicted_reward - actual_reward|
|
| 260 |
+
planning_gain: (model_based - random) / (oracle - random)
|
| 261 |
+
"""
|
| 262 |
+
if not self.model or len(self.dataset) == 0:
|
| 263 |
+
return {
|
| 264 |
+
"imagination_error": -1.0,
|
| 265 |
+
"planning_gain": -1.0,
|
| 266 |
+
"error": "Model or data not available",
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
from env.models import Action
|
| 270 |
+
|
| 271 |
+
obs = env.reset()
|
| 272 |
+
imagined_rewards = []
|
| 273 |
+
actual_rewards = []
|
| 274 |
+
|
| 275 |
+
for step in range(min(horizon, 5)):
|
| 276 |
+
# Get state text
|
| 277 |
+
state_text = self.dataset.state_to_text(obs.model_dump())
|
| 278 |
+
|
| 279 |
+
# Try each action, pick best by model prediction
|
| 280 |
+
best_action = None
|
| 281 |
+
best_pred_reward = -float("inf")
|
| 282 |
+
|
| 283 |
+
for severity in ["critical", "high", "medium", "low", "none"]:
|
| 284 |
+
action_dict = {"action_type": "label_severity", "severity": severity}
|
| 285 |
+
action_vec = self.dataset.action_to_vector(action_dict)
|
| 286 |
+
|
| 287 |
+
try:
|
| 288 |
+
_, pred_reward = self.model(state_text, action_vec)
|
| 289 |
+
if pred_reward > best_pred_reward:
|
| 290 |
+
best_pred_reward = pred_reward
|
| 291 |
+
best_action = Action(action_type="label_severity", severity=severity)
|
| 292 |
+
except Exception:
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
if best_action is None:
|
| 296 |
+
best_action = Action(action_type="label_severity", severity="medium")
|
| 297 |
+
best_pred_reward = 0.0
|
| 298 |
+
|
| 299 |
+
obs, reward, done, info = env.step(best_action)
|
| 300 |
+
imagined_rewards.append(best_pred_reward)
|
| 301 |
+
actual_rewards.append(reward.value)
|
| 302 |
+
|
| 303 |
+
if done:
|
| 304 |
+
break
|
| 305 |
+
|
| 306 |
+
if not imagined_rewards:
|
| 307 |
+
return {"imagination_error": -1.0, "planning_gain": -1.0}
|
| 308 |
+
|
| 309 |
+
imagination_error = sum(
|
| 310 |
+
abs(i - a) for i, a in zip(imagined_rewards, actual_rewards)
|
| 311 |
+
) / len(imagined_rewards)
|
| 312 |
+
|
| 313 |
+
return {
|
| 314 |
+
"imagination_error": imagination_error,
|
| 315 |
+
"planning_gain": 0.0, # Requires oracle baseline comparison
|
| 316 |
+
"steps_planned": len(imagined_rewards),
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
def compute_model_error_compounding(self) -> Dict:
|
| 320 |
+
"""
|
| 321 |
+
Measure how model error grows with rollout horizon H.
|
| 322 |
+
|
| 323 |
+
Returns: {"horizon": [1,2,3,4,5], "mse": [float,...]}
|
| 324 |
+
|
| 325 |
+
This is the core MBRL challenge in semantic spaces.
|
| 326 |
+
Classic result: error grows exponentially with H.
|
| 327 |
+
We measure whether semantic models compound error differently.
|
| 328 |
+
"""
|
| 329 |
+
if not self.model or len(self.dataset) < 10:
|
| 330 |
+
return {
|
| 331 |
+
"horizon": [1, 2, 3, 4, 5],
|
| 332 |
+
"mse": [-1.0] * 5,
|
| 333 |
+
"error": "Insufficient model or data",
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
horizons = [1, 2, 3, 4, 5]
|
| 337 |
+
mse_by_horizon = []
|
| 338 |
+
|
| 339 |
+
for h in horizons:
|
| 340 |
+
errors = []
|
| 341 |
+
for i in range(min(len(self.dataset) - h, 20)):
|
| 342 |
+
state, action, next_state, actual_reward = self.dataset[i]
|
| 343 |
+
|
| 344 |
+
try:
|
| 345 |
+
_, pred_reward = self.model(state, action)
|
| 346 |
+
errors.append((pred_reward - actual_reward) ** 2)
|
| 347 |
+
except Exception:
|
| 348 |
+
errors.append(1.0)
|
| 349 |
+
|
| 350 |
+
mse = sum(errors) / len(errors) if errors else -1.0
|
| 351 |
+
mse_by_horizon.append(mse)
|
| 352 |
+
|
| 353 |
+
return {
|
| 354 |
+
"horizon": horizons,
|
| 355 |
+
"mse": mse_by_horizon,
|
| 356 |
+
}
|