Spaces:
Sleeping
Sleeping
Deploy Enterprise Contract Guardian — finale build
Browse files- .env.example +25 -0
- Dockerfile +0 -1
- README.md +199 -46
- client.py +24 -0
- inference.py +351 -91
- models.py +131 -16
- pyproject.toml +1 -0
- results/.gitkeep +0 -0
- results/baseline_table.md +11 -0
- server/app.py +3 -0
- server/environment.py +556 -8
- server/fix_validator.py +227 -0
- server/impact_tracer.py +95 -0
- server/logging_setup.py +49 -0
- server/rewards.py +259 -61
- server/service_graph.py +447 -0
- tests/test_environment.py +150 -0
- training/README.md +80 -0
- training/baseline.py +109 -0
- training/grpo_colab.ipynb +215 -0
- training/plot.py +109 -0
- training/train.py +300 -0
.env.example
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy this file to `.env` and fill in your real values.
|
| 2 |
+
# `.env` is gitignored — your token will NOT be committed.
|
| 3 |
+
#
|
| 4 |
+
# cp .env.example .env
|
| 5 |
+
# # then edit .env with your token
|
| 6 |
+
|
| 7 |
+
# ---- Required for inference ---------------------------------------------
|
| 8 |
+
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 9 |
+
API_BASE_URL=https://router.huggingface.co/v1
|
| 10 |
+
MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
|
| 11 |
+
|
| 12 |
+
# ---- Optional: where to write the per-task scores -----------------------
|
| 13 |
+
# Set this to enable JSON output. Path is relative to where you run python.
|
| 14 |
+
# SCORES_OUT_PATH=../baseline_scores.json
|
| 15 |
+
|
| 16 |
+
# ---- Optional: env server URL (default = local Docker) ------------------
|
| 17 |
+
# ENV_BASE_URL=http://localhost:7860
|
| 18 |
+
# Or your deployed HF Space:
|
| 19 |
+
# ENV_BASE_URL=https://YOUR_USERNAME-api-contract-validator.hf.space
|
| 20 |
+
|
| 21 |
+
# ---- Optional: training-only ---------------------------------------------
|
| 22 |
+
# WANDB_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 23 |
+
# WANDB_PROJECT=openenv-contract-guardian
|
| 24 |
+
# BASE_MODEL=unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit
|
| 25 |
+
# PUSH_TO_HUB=YOUR_USERNAME/api-contract-validator-grpo
|
Dockerfile
CHANGED
|
@@ -25,5 +25,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
| 25 |
CMD curl -f http://localhost:7860/health || exit 1
|
| 26 |
|
| 27 |
# Run the FastAPI server
|
| 28 |
-
ENV ENABLE_WEB_INTERFACE=true
|
| 29 |
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 25 |
CMD curl -f http://localhost:7860/health || exit 1
|
| 26 |
|
| 27 |
# Run the FastAPI server
|
|
|
|
| 28 |
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -8,47 +8,87 @@ app_port: 7860
|
|
| 8 |
tags:
|
| 9 |
- openenv
|
| 10 |
pinned: false
|
| 11 |
-
base_path: /web
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
required fields, invalid enum values, format constraint violations, breaking API
|
| 19 |
-
changes, cross-field arithmetic errors, and authentication schema violations.
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
**Real-world applications:**
|
| 29 |
-
- CI/CD
|
| 30 |
-
-
|
| 31 |
-
-
|
| 32 |
-
-
|
| 33 |
-
-
|
| 34 |
|
| 35 |
## How It Works
|
| 36 |
|
| 37 |
-
Each episode
|
| 38 |
-
1. An **OpenAPI specification** defining expected types, required fields, and constraints
|
| 39 |
-
2. A **payload** containing planted violations
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
```
|
| 45 |
-
reset()
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
```
|
| 50 |
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
| Task | Difficulty | Violations | Max Steps | What the Agent Must Find |
|
| 54 |
|------|-----------|------------|-----------|--------------------------|
|
|
@@ -59,6 +99,19 @@ step(DONE) → Episode ends with completeness bonus +0.5 × (found/total)
|
|
| 59 |
| `validate_cross_field_constraints` | Expert | 7 | 18 | Cross-field arithmetic and date ordering on Invoice API — line totals, subtotal sum, tax calculation, discount rules for trial accounts |
|
| 60 |
| `validate_auth_request` | Expert | 6 | 14 | OAuth2 token and API key management violations — invalid grant types, bad scopes, MFA token patterns, IP format, rate limits. 2 variants |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
### Randomised Episode Generation
|
| 63 |
|
| 64 |
All tasks support seed-based randomisation, making the environment suitable for
|
|
@@ -72,15 +125,35 @@ All tasks support seed-based randomisation, making the environment suitable for
|
|
| 72 |
|
| 73 |
## Action Space
|
| 74 |
|
| 75 |
-
Each step the agent submits a `ValidatorAction`
|
|
|
|
|
|
|
| 76 |
|
| 77 |
| Field | Type | Description |
|
| 78 |
|-------|------|-------------|
|
|
|
|
| 79 |
| `field_path` | `str` | Dot-notation path to the violated field (e.g. `customer.email`, `items[1].quantity`). Special values: `DONE` to end episode, `HINT` for a location clue |
|
| 80 |
| `violation_type` | `str` | One of: `type_mismatch`, `missing_required`, `invalid_enum`, `format_error`, `extra_field`, `breaking_change`, `cross_field_constraint` |
|
| 81 |
| `description` | `str` | Human-readable explanation of the violation |
|
| 82 |
| `suggested_fix` | `str` | Optional suggested correction |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
## Observation Space
|
| 85 |
|
| 86 |
After each step the agent receives a `ValidatorObservation`:
|
|
@@ -100,7 +173,9 @@ After each step the agent receives a `ValidatorObservation`:
|
|
| 100 |
|
| 101 |
## Reward Function
|
| 102 |
|
| 103 |
-
|
|
|
|
|
|
|
| 104 |
|
| 105 |
| Event | Reward | Rationale |
|
| 106 |
|-------|--------|-----------|
|
|
@@ -111,7 +186,32 @@ Partial progress signals — not binary end-of-episode scoring:
|
|
| 111 |
| False positive | **−0.3** | Penalises guessing |
|
| 112 |
| DONE signal | **+0.5 × (found/total)** | Completeness bonus |
|
| 113 |
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
## Setup
|
| 117 |
|
|
@@ -154,18 +254,66 @@ python inference.py
|
|
| 154 |
openenv validate
|
| 155 |
```
|
| 156 |
|
| 157 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|------|-------|-------|-------|
|
| 161 |
-
| `find_type_mismatches` | Qwen2.5-72B-Instruct | ~0.75 | 5–7 |
|
| 162 |
-
| `validate_nested_objects` | Qwen2.5-72B-Instruct | ~0.57 | 8–12 |
|
| 163 |
-
| `detect_breaking_changes` | Qwen2.5-72B-Instruct | ~0.44 | 12–18 |
|
| 164 |
-
| `validate_response_schema` | Qwen2.5-72B-Instruct | ~0.40 | 15–22 |
|
| 165 |
-
| `validate_cross_field_constraints` | Qwen2.5-72B-Instruct | ~0.43 | 10–16 |
|
| 166 |
-
| `validate_auth_request` | Qwen2.5-72B-Instruct | ~0.60 | 8–12 |
|
| 167 |
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
## Project Structure
|
| 171 |
|
|
@@ -174,18 +322,23 @@ api_contract_validator/
|
|
| 174 |
├── openenv.yaml # OpenEnv manifest
|
| 175 |
├── pyproject.toml # Python project metadata
|
| 176 |
├── Dockerfile # Container definition
|
| 177 |
-
├── inference.py # Baseline inference script
|
| 178 |
├── README.md # This file
|
| 179 |
-
├── models.py # Pydantic models (Action, Observation, State)
|
| 180 |
├── client.py # WebSocket client (EnvClient subclass)
|
| 181 |
├── __init__.py # Package exports
|
|
|
|
|
|
|
|
|
|
| 182 |
└── server/
|
| 183 |
-
├── __init__.py
|
| 184 |
├── app.py # FastAPI wiring (create_app)
|
| 185 |
-
├── environment.py # Core environment
|
| 186 |
-
├──
|
| 187 |
-
├──
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
| 189 |
```
|
| 190 |
|
| 191 |
## License
|
|
|
|
| 8 |
tags:
|
| 9 |
- openenv
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Enterprise Contract Guardian — OpenEnv Environment
|
| 14 |
|
| 15 |
+
> **Meta PyTorch OpenEnv Hackathon × Scaler School of Technology — Grand Finale Submission**
|
| 16 |
+
> **Theme #3.1**: World Modeling → Professional Tasks · ⭐ **Scaler AI Labs bonus track**: Multi-App RL Environment for Enterprise Workflows
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
An OpenEnv RL environment that trains agents to do what senior platform engineers do when an API breaks in production: **detect the violation, trace which downstream services are affected, propose a backward-compatible fix, and verify the fix doesn't cascade**.
|
| 19 |
|
| 20 |
+
## The Story
|
| 21 |
+
|
| 22 |
+
> An engineer ships a "small" change to the Users API on Friday evening. It passes local tests. On Monday, **four downstream teams break** — the Orders service, the Billing pipeline, the Notification worker, and the Analytics ETL. The root cause: a single field renamed in one spec, with no awareness of who consumed it.
|
| 23 |
+
>
|
| 24 |
+
> This environment teaches agents the full workflow — not just "find the bug," but **reason about blast radius, propose fixes that preserve compatibility, and verify the migration across every consumer.**
|
| 25 |
+
|
| 26 |
+
## Why This Environment Matters (Theme #3.1 Alignment)
|
| 27 |
+
|
| 28 |
+
Per `themes.md` Theme #3.1: *"environments that require real interaction with tools, APIs, or dynamic systems where the model is expected to do real hard work instead of exploiting short-cuts."*
|
| 29 |
+
|
| 30 |
+
- ✅ **Real tools/APIs**: OpenAPI specs, payloads, consumer service graphs
|
| 31 |
+
- ✅ **Partially observable world**: agent discovers the consumer graph through queries
|
| 32 |
+
- ✅ **Persistent state**: violations found, consumers traced, fixes proposed build up across steps
|
| 33 |
+
- ✅ **Multi-step orchestration**: `detect → trace → propose → validate`
|
| 34 |
+
- ✅ **Enterprise workflow nuance**: versioning, deprecation, backward compatibility
|
| 35 |
+
- ✅ **Verifiable reward**: every step has a deterministic, objective grader
|
| 36 |
+
|
| 37 |
+
## Architecture: Phase 1 → Phase 2 → Phase 3
|
| 38 |
+
|
| 39 |
+
| Phase | What the agent does | Task examples |
|
| 40 |
+
|---|---|---|
|
| 41 |
+
| **Phase 1 — Detection** (inherited from Round 1) | Read one OpenAPI spec + payload, report violations | `find_type_mismatches`, `validate_nested_objects`, `detect_breaking_changes` |
|
| 42 |
+
| **Phase 2 — Impact Tracing** | Given a detected breaking change, identify all downstream consumers whose contracts are violated | `trace_downstream_blast_radius` |
|
| 43 |
+
| **Phase 3 — Fix & Verify** | Propose a backward-compatible migration; verify against every consumer spec | `propose_backward_compat_fix`, `multi_service_cascade_fix` |
|
| 44 |
|
| 45 |
**Real-world applications:**
|
| 46 |
+
- CI/CD contract gate that blocks a PR with predicted downstream impact
|
| 47 |
+
- Automated migration-plan generator for API versioning
|
| 48 |
+
- Enterprise API gateway pre-deployment safety check
|
| 49 |
+
- SDK compatibility auditor across microservices
|
| 50 |
+
- OAuth2/auth schema change impact analysis
|
| 51 |
|
| 52 |
## How It Works
|
| 53 |
|
| 54 |
+
Each episode places the agent inside a **simulated enterprise** with 3–5 microservices, each owning an OpenAPI spec and declaring which other services consume it.
|
|
|
|
|
|
|
| 55 |
|
| 56 |
+
```
|
| 57 |
+
Enterprise Service Graph
|
| 58 |
+
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
| 59 |
+
│ UsersService │ ─────▶ │ OrdersService │ ─────▶ │BillingService│
|
| 60 |
+
└──────────────┘ └──────────────┘ └──────────────┘
|
| 61 |
+
│ │
|
| 62 |
+
▼ ▼
|
| 63 |
+
┌───────────────────┐ ┌──────────────────┐
|
| 64 |
+
│ NotificationsSvc │ │ AnalyticsETL │
|
| 65 |
+
└──���────────────────┘ └──────────────────┘
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
### Episode Flow (Phase 2/3 tasks)
|
| 69 |
|
| 70 |
```
|
| 71 |
+
reset()
|
| 72 |
+
→ Agent receives: a changed spec (producer) + partial service graph.
|
| 73 |
+
|
| 74 |
+
Phase 1 — Detection
|
| 75 |
+
step(violation_report) → Correct? +1.0 | Proximity +0.3 | False positive -0.3 | Duplicate -0.1
|
| 76 |
+
|
| 77 |
+
Phase 2 — Impact Tracing
|
| 78 |
+
step(trace_impact) → For each consumer correctly flagged: +reward; missed consumer: penalty
|
| 79 |
+
|
| 80 |
+
Phase 3 — Fix Proposal
|
| 81 |
+
step(propose_fix) → Fix validates against ALL consumers: +big reward | breaks ≥1 consumer: penalty
|
| 82 |
+
step(validate_fix) → Deterministic cross-spec check confirms/rejects the fix
|
| 83 |
+
|
| 84 |
+
step(DONE) → Completeness bonus = 0.5 × (correct_violations / total) × (consumers_traced / total) × fix_valid
|
| 85 |
```
|
| 86 |
|
| 87 |
+
Phase 1 tasks retain the simple single-spec flow (used as curriculum starters — `help_guide.md §6`).
|
| 88 |
+
|
| 89 |
+
## Tasks
|
| 90 |
+
|
| 91 |
+
### Phase 1 — Detection (curriculum starters, inherited from Round 1)
|
| 92 |
|
| 93 |
| Task | Difficulty | Violations | Max Steps | What the Agent Must Find |
|
| 94 |
|------|-----------|------------|-----------|--------------------------|
|
|
|
|
| 99 |
| `validate_cross_field_constraints` | Expert | 7 | 18 | Cross-field arithmetic and date ordering on Invoice API — line totals, subtotal sum, tax calculation, discount rules for trial accounts |
|
| 100 |
| `validate_auth_request` | Expert | 6 | 14 | OAuth2 token and API key management violations — invalid grant types, bad scopes, MFA token patterns, IP format, rate limits. 2 variants |
|
| 101 |
|
| 102 |
+
### Phase 2 — Impact Tracing (finale — multi-service)
|
| 103 |
+
|
| 104 |
+
| Task | Difficulty | Max Steps | What the Agent Must Do |
|
| 105 |
+
|---|---|---|---|
|
| 106 |
+
| `trace_downstream_blast_radius` | Hard | 20 | Given a breaking change in a producer spec + a consumer service graph, identify every downstream service whose contract is violated. Graded on precision + recall against ground-truth consumer impact. |
|
| 107 |
+
|
| 108 |
+
### Phase 3 — Fix & Verify (finale — full workflow)
|
| 109 |
+
|
| 110 |
+
| Task | Difficulty | Max Steps | What the Agent Must Do |
|
| 111 |
+
|---|---|---|---|
|
| 112 |
+
| `propose_backward_compat_fix` | Expert | 25 | Given a detected breaking change, propose a migration (aliasing, deprecation, version bump). Graded by whether the fix validates against all consumer specs. |
|
| 113 |
+
| `multi_service_cascade_fix` | Expert | 40 | Full workflow: `detect → trace → propose → validate` in one episode, across 3–5 services. Sparse reward with per-phase sub-rewards. |
|
| 114 |
+
|
| 115 |
### Randomised Episode Generation
|
| 116 |
|
| 117 |
All tasks support seed-based randomisation, making the environment suitable for
|
|
|
|
| 125 |
|
| 126 |
## Action Space
|
| 127 |
|
| 128 |
+
Each step the agent submits a `ValidatorAction`. The action type it sends depends on the current episode phase.
|
| 129 |
+
|
| 130 |
+
### Detection actions (Phase 1)
|
| 131 |
|
| 132 |
| Field | Type | Description |
|
| 133 |
|-------|------|-------------|
|
| 134 |
+
| `action_type` | `str` | `report_violation` |
|
| 135 |
| `field_path` | `str` | Dot-notation path to the violated field (e.g. `customer.email`, `items[1].quantity`). Special values: `DONE` to end episode, `HINT` for a location clue |
|
| 136 |
| `violation_type` | `str` | One of: `type_mismatch`, `missing_required`, `invalid_enum`, `format_error`, `extra_field`, `breaking_change`, `cross_field_constraint` |
|
| 137 |
| `description` | `str` | Human-readable explanation of the violation |
|
| 138 |
| `suggested_fix` | `str` | Optional suggested correction |
|
| 139 |
|
| 140 |
+
### Impact tracing actions (Phase 2 — finale)
|
| 141 |
+
|
| 142 |
+
| Field | Type | Description |
|
| 143 |
+
|---|---|---|
|
| 144 |
+
| `action_type` | `str` | `trace_impact` |
|
| 145 |
+
| `affected_services` | `list[str]` | Names of downstream services the agent believes are impacted |
|
| 146 |
+
| `reasoning` | `str` | Brief justification for each entry |
|
| 147 |
+
|
| 148 |
+
### Fix-proposal actions (Phase 3 — finale)
|
| 149 |
+
|
| 150 |
+
| Field | Type | Description |
|
| 151 |
+
|---|---|---|
|
| 152 |
+
| `action_type` | `str` | `propose_fix` or `validate_fix` |
|
| 153 |
+
| `fix_strategy` | `str` | One of: `field_alias`, `version_bump`, `deprecation_window`, `dual_write`, `consumer_patch` |
|
| 154 |
+
| `spec_patch` | `dict` | JSON patch to apply to the producer spec |
|
| 155 |
+
| `rationale` | `str` | Why this preserves backward compatibility |
|
| 156 |
+
|
| 157 |
## Observation Space
|
| 158 |
|
| 159 |
After each step the agent receives a `ValidatorObservation`:
|
|
|
|
| 173 |
|
| 174 |
## Reward Function
|
| 175 |
|
| 176 |
+
Multiple **independent** reward signals (per `help_guide.md §7`) — reduces reward-hacking risk, provides rich training signal.
|
| 177 |
+
|
| 178 |
+
### Detection rewards (Phase 1)
|
| 179 |
|
| 180 |
| Event | Reward | Rationale |
|
| 181 |
|-------|--------|-----------|
|
|
|
|
| 186 |
| False positive | **−0.3** | Penalises guessing |
|
| 187 |
| DONE signal | **+0.5 × (found/total)** | Completeness bonus |
|
| 188 |
|
| 189 |
+
### Impact-tracing rewards (Phase 2)
|
| 190 |
+
|
| 191 |
+
| Event | Reward | Rationale |
|
| 192 |
+
|---|---|---|
|
| 193 |
+
| Correctly identified affected consumer | **+0.8** | Reward recall |
|
| 194 |
+
| Missed affected consumer | **−0.5** | Penalise under-reporting |
|
| 195 |
+
| False-flag unaffected consumer | **−0.4** | Penalise over-reporting |
|
| 196 |
+
|
| 197 |
+
### Fix-proposal rewards (Phase 3)
|
| 198 |
+
|
| 199 |
+
| Event | Reward | Rationale |
|
| 200 |
+
|---|---|---|
|
| 201 |
+
| Fix validates against ALL consumers | **+2.0** | Major incentive — this is the goal |
|
| 202 |
+
| Fix breaks 1+ consumer | **−1.0** | Must be backward compatible |
|
| 203 |
+
| Malformed spec patch | **−0.5** | Format compliance |
|
| 204 |
+
| Invalid strategy for this violation class | **−0.3** | Encourages strategy selection |
|
| 205 |
+
|
| 206 |
+
### Cross-cutting signals
|
| 207 |
+
|
| 208 |
+
| Signal | Reward | Rationale (`help_guide.md §7`) |
|
| 209 |
+
|---|---|---|
|
| 210 |
+
| **Step efficiency** | +0.05 per unused step at DONE | Discourages padding |
|
| 211 |
+
| **Format compliance** | −0.2 for malformed actions | Enforces schema |
|
| 212 |
+
| **Anti-hacking (spam)** | −1.0 if > 3× total violations reported | Prevents "report everything" exploit |
|
| 213 |
+
|
| 214 |
+
**Final episode score** = weighted blend of phase scores; see `server/rewards.py`.
|
| 215 |
|
| 216 |
## Setup
|
| 217 |
|
|
|
|
| 254 |
openenv validate
|
| 255 |
```
|
| 256 |
|
| 257 |
+
## Training Results
|
| 258 |
+
|
| 259 |
+
> **Training**: GRPO via TRL + Unsloth · **Hardware**: HuggingFace Jobs T4 GPU
|
| 260 |
+
|
| 261 |
+
### Reward Curve
|
| 262 |
+
|
| 263 |
+
*Training plots will be embedded here after onsite training (Apr 25–26).*
|
| 264 |
+
|
| 265 |
+
<!-- After training, replace with:
|
| 266 |
+

|
| 267 |
+
*Episode reward over training steps. Baseline (untrained) vs GRPO-trained agent. x-axis: training step, y-axis: episode reward (0–1).*
|
| 268 |
+
|
| 269 |
+

|
| 270 |
+
*Per-task score comparison. Baseline model (blue) vs trained checkpoint (green).*
|
| 271 |
+
-->
|
| 272 |
+
|
| 273 |
+
| Phase | WandB Run | Notebook |
|
| 274 |
+
|---|---|---|
|
| 275 |
+
| GRPO (Phase 1 + Phase 2/3) | *(link after training)* | [`training/grpo_colab.ipynb`](training/grpo_colab.ipynb) |
|
| 276 |
+
|
| 277 |
+
See [`training/README.md`](training/README.md) for the three ways to run the pipeline (Colab / HF Jobs / local).
|
| 278 |
+
|
| 279 |
+
### Baseline Scores (pre-training, Qwen2.5-72B-Instruct, recorded 2026-04-25)
|
| 280 |
+
|
| 281 |
+
| Task | Phase | Score | Steps | Success |
|
| 282 |
+
|---|---|---|---|---|
|
| 283 |
+
| `find_type_mismatches` | 1 | 0.75 | 4 | ✅ |
|
| 284 |
+
| `validate_nested_objects` | 1 | 0.99 | 12 | ✅ |
|
| 285 |
+
| `detect_breaking_changes` | 1 | **0.01** | 20 | ⛔ |
|
| 286 |
+
| `validate_response_schema` | 1 | 0.99 | 10 | ✅ |
|
| 287 |
+
| `validate_cross_field_constraints` | 1 | 0.86 | 8 | ✅ |
|
| 288 |
+
| `validate_auth_request` | 1 | 0.99 | 10 | ✅ |
|
| 289 |
+
| `trace_downstream_blast_radius` | 2 | 0.67 | 1 | ✅ |
|
| 290 |
+
| `propose_backward_compat_fix` | 3 | 0.99 | 1 | ✅ |
|
| 291 |
+
| `multi_service_cascade_fix` | 2+3 | 0.99 | 2 | ✅ |
|
| 292 |
+
|
| 293 |
+
Full per-step rewards in [`../baseline_scores.json`](../baseline_scores.json).
|
| 294 |
+
|
| 295 |
+
**Headroom for training**: `detect_breaking_changes` at 0.01 is the biggest opportunity — the 72B model finds the right field paths (proximity hits) but never predicts `violation_type='breaking_change'` correctly. Phase 2 trace is also under-shooting recall. After GRPO training the trained-model row will go alongside this table.
|
| 296 |
+
|
| 297 |
+
## Why This Matters
|
| 298 |
+
|
| 299 |
+
API contract violations are the **#1 cause of production incidents in microservice architectures**. Every platform team deals with this weekly. No existing RL environment teaches agents to reason about multi-service contract impact.
|
| 300 |
+
|
| 301 |
+
**Who benefits from an agent trained on this environment:**
|
| 302 |
+
- Platform / API gateway teams — pre-merge contract safety checks
|
| 303 |
+
- CI/CD pipelines — automated impact analysis before deploy
|
| 304 |
+
- API versioning toolchains — backward-compat migration planning
|
| 305 |
+
- Any engineering org operating ≥ 3 microservices
|
| 306 |
+
|
| 307 |
+
This is a genuinely underexplored domain in RL/LLM training — no prior benchmarks exist for multi-service API contract reasoning. A model trained here would be publishable as a research artifact.
|
| 308 |
|
| 309 |
+
## Links
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
+
| Resource | URL |
|
| 312 |
+
|---|---|
|
| 313 |
+
| HuggingFace Space | *(deploy link — add after deployment)* |
|
| 314 |
+
| Training Notebook (Colab) | *(add after onsite training)* |
|
| 315 |
+
| Demo Video / HF Blog | *(add after recording)* |
|
| 316 |
+
| WandB Training Run | *(add after training)* |
|
| 317 |
|
| 318 |
## Project Structure
|
| 319 |
|
|
|
|
| 322 |
├── openenv.yaml # OpenEnv manifest
|
| 323 |
├── pyproject.toml # Python project metadata
|
| 324 |
├── Dockerfile # Container definition
|
| 325 |
+
├── inference.py # Baseline inference script (OpenAI client, phase-aware)
|
| 326 |
├── README.md # This file
|
| 327 |
+
├── models.py # Pydantic models (Action, Observation, State — all 3 phases)
|
| 328 |
├── client.py # WebSocket client (EnvClient subclass)
|
| 329 |
├── __init__.py # Package exports
|
| 330 |
+
├── results/ # Training plots (.png) — committed, embedded above
|
| 331 |
+
├── tests/
|
| 332 |
+
│ └── test_environment.py # 28 tests across all 3 phases
|
| 333 |
└── server/
|
|
|
|
| 334 |
├── app.py # FastAPI wiring (create_app)
|
| 335 |
+
├── environment.py # Core environment — multi-phase orchestration
|
| 336 |
+
├── logging_setup.py # Structured JSON episode logging
|
| 337 |
+
├── spec_generator.py # Phase 1 — task scenarios with planted violations
|
| 338 |
+
├── service_graph.py # Phase 2 — simulated enterprise service graph
|
| 339 |
+
├── impact_tracer.py # Phase 2 — ground-truth consumer-impact computation
|
| 340 |
+
├── fix_validator.py # Phase 3 — cross-spec fix verification
|
| 341 |
+
└── rewards.py # Composable reward rubrics (multi-phase, independent signals)
|
| 342 |
```
|
| 343 |
|
| 344 |
## License
|
client.py
CHANGED
|
@@ -44,10 +44,19 @@ class ValidatorEnv(
|
|
| 44 |
def _step_payload(self, action: ValidatorAction) -> Dict[str, Any]:
|
| 45 |
"""Convert action to JSON payload for the step message."""
|
| 46 |
return {
|
|
|
|
|
|
|
| 47 |
"field_path": action.field_path,
|
| 48 |
"violation_type": action.violation_type,
|
| 49 |
"description": action.description,
|
| 50 |
"suggested_fix": action.suggested_fix,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
|
| 53 |
def _parse_result(
|
|
@@ -60,10 +69,17 @@ class ValidatorEnv(
|
|
| 60 |
reward=payload.get("reward"),
|
| 61 |
task_name=obs_data.get("task_name", ""),
|
| 62 |
task_description=obs_data.get("task_description", ""),
|
|
|
|
| 63 |
api_spec=obs_data.get("api_spec", {}),
|
| 64 |
payload=obs_data.get("payload", {}),
|
| 65 |
violations_found=obs_data.get("violations_found", []),
|
| 66 |
violations_remaining=obs_data.get("violations_remaining", 0),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
feedback=obs_data.get("feedback", ""),
|
| 68 |
max_steps=obs_data.get("max_steps", 0),
|
| 69 |
)
|
|
@@ -79,9 +95,17 @@ class ValidatorEnv(
|
|
| 79 |
episode_id=payload.get("episode_id"),
|
| 80 |
step_count=payload.get("step_count", 0),
|
| 81 |
task_name=payload.get("task_name", ""),
|
|
|
|
| 82 |
total_violations=payload.get("total_violations", 0),
|
| 83 |
correct_reports=payload.get("correct_reports", 0),
|
| 84 |
false_positives=payload.get("false_positives", 0),
|
| 85 |
duplicate_reports=payload.get("duplicate_reports", 0),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
score=payload.get("score", 0.0),
|
| 87 |
)
|
|
|
|
| 44 |
def _step_payload(self, action: ValidatorAction) -> Dict[str, Any]:
|
| 45 |
"""Convert action to JSON payload for the step message."""
|
| 46 |
return {
|
| 47 |
+
"action_type": action.action_type,
|
| 48 |
+
# Phase 1
|
| 49 |
"field_path": action.field_path,
|
| 50 |
"violation_type": action.violation_type,
|
| 51 |
"description": action.description,
|
| 52 |
"suggested_fix": action.suggested_fix,
|
| 53 |
+
# Phase 2
|
| 54 |
+
"affected_services": list(action.affected_services),
|
| 55 |
+
"reasoning": action.reasoning,
|
| 56 |
+
# Phase 3
|
| 57 |
+
"fix_strategy": action.fix_strategy,
|
| 58 |
+
"spec_patch": dict(action.spec_patch),
|
| 59 |
+
"rationale": action.rationale,
|
| 60 |
}
|
| 61 |
|
| 62 |
def _parse_result(
|
|
|
|
| 69 |
reward=payload.get("reward"),
|
| 70 |
task_name=obs_data.get("task_name", ""),
|
| 71 |
task_description=obs_data.get("task_description", ""),
|
| 72 |
+
phase=obs_data.get("phase", "detection"),
|
| 73 |
api_spec=obs_data.get("api_spec", {}),
|
| 74 |
payload=obs_data.get("payload", {}),
|
| 75 |
violations_found=obs_data.get("violations_found", []),
|
| 76 |
violations_remaining=obs_data.get("violations_remaining", 0),
|
| 77 |
+
service_graph=obs_data.get("service_graph", {}),
|
| 78 |
+
consumers_traced=obs_data.get("consumers_traced", []),
|
| 79 |
+
total_consumers=obs_data.get("total_consumers", 0),
|
| 80 |
+
detected_violation=obs_data.get("detected_violation", {}),
|
| 81 |
+
consumer_specs=obs_data.get("consumer_specs", {}),
|
| 82 |
+
fix_validation_results=obs_data.get("fix_validation_results", {}),
|
| 83 |
feedback=obs_data.get("feedback", ""),
|
| 84 |
max_steps=obs_data.get("max_steps", 0),
|
| 85 |
)
|
|
|
|
| 95 |
episode_id=payload.get("episode_id"),
|
| 96 |
step_count=payload.get("step_count", 0),
|
| 97 |
task_name=payload.get("task_name", ""),
|
| 98 |
+
phase=payload.get("phase", "detection"),
|
| 99 |
total_violations=payload.get("total_violations", 0),
|
| 100 |
correct_reports=payload.get("correct_reports", 0),
|
| 101 |
false_positives=payload.get("false_positives", 0),
|
| 102 |
duplicate_reports=payload.get("duplicate_reports", 0),
|
| 103 |
+
total_consumers=payload.get("total_consumers", 0),
|
| 104 |
+
consumers_correctly_traced=payload.get("consumers_correctly_traced", 0),
|
| 105 |
+
consumers_missed=payload.get("consumers_missed", 0),
|
| 106 |
+
consumers_false_flagged=payload.get("consumers_false_flagged", 0),
|
| 107 |
+
fix_attempts=payload.get("fix_attempts", 0),
|
| 108 |
+
fix_validated=payload.get("fix_validated", False),
|
| 109 |
+
fix_breaks_consumers=payload.get("fix_breaks_consumers", 0),
|
| 110 |
score=payload.get("score", 0.0),
|
| 111 |
)
|
inference.py
CHANGED
|
@@ -20,8 +20,20 @@ import asyncio
|
|
| 20 |
import json
|
| 21 |
import os
|
| 22 |
import textwrap
|
|
|
|
| 23 |
from typing import Any, Dict, List, Optional
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
from openai import OpenAI
|
| 26 |
|
| 27 |
from client import ValidatorEnv
|
|
@@ -35,9 +47,10 @@ LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
|
|
| 35 |
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 36 |
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 37 |
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
|
|
|
| 38 |
|
| 39 |
BENCHMARK = "api_contract_validator"
|
| 40 |
-
|
| 41 |
"find_type_mismatches",
|
| 42 |
"validate_nested_objects",
|
| 43 |
"detect_breaking_changes",
|
|
@@ -45,6 +58,11 @@ TASKS = [
|
|
| 45 |
"validate_cross_field_constraints",
|
| 46 |
"validate_auth_request",
|
| 47 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
MAX_STEPS_PER_TASK = {
|
| 49 |
"find_type_mismatches": 10,
|
| 50 |
"validate_nested_objects": 15,
|
|
@@ -52,6 +70,9 @@ MAX_STEPS_PER_TASK = {
|
|
| 52 |
"validate_response_schema": 25,
|
| 53 |
"validate_cross_field_constraints": 18,
|
| 54 |
"validate_auth_request": 14,
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
MAX_CONSECUTIVE_FAILURES = 3 # stop retrying same field after this many -0.3 rewards
|
| 57 |
TEMPERATURE = 0.2
|
|
@@ -94,7 +115,7 @@ def log_end(
|
|
| 94 |
# Prompt construction
|
| 95 |
# ---------------------------------------------------------------------------
|
| 96 |
|
| 97 |
-
|
| 98 |
You are an expert API contract validator. You will be given an OpenAPI \
|
| 99 |
specification and an API payload. Your job is to find ALL violations in the \
|
| 100 |
payload that do not conform to the spec.
|
|
@@ -102,6 +123,7 @@ payload that do not conform to the spec.
|
|
| 102 |
Each turn you must respond with EXACTLY one JSON object (no markdown, no \
|
| 103 |
explanation outside the JSON):
|
| 104 |
{
|
|
|
|
| 105 |
"field_path": "<dot-notation path to the violated field, or 'DONE' if finished>",
|
| 106 |
"violation_type": "<type_mismatch|missing_required|invalid_enum|format_error|extra_field|breaking_change|cross_field_constraint>",
|
| 107 |
"description": "<brief explanation of the violation>",
|
|
@@ -117,7 +139,7 @@ STRICT RULES:
|
|
| 117 |
- missing_required: required field absent from payload
|
| 118 |
- invalid_enum: value not in the allowed enum list
|
| 119 |
- format_error: value violates format/pattern/min/max constraint
|
| 120 |
-
- breaking_change: API v1→v2 change that breaks existing clients
|
| 121 |
- cross_field_constraint: arithmetic/date/conditional rule across multiple fields
|
| 122 |
5. Do NOT repeat a violation already in 'Violations found so far'.
|
| 123 |
6. If last feedback was 'False positive' or negative reward, that field is WRONG — move to a different field.
|
|
@@ -125,26 +147,149 @@ STRICT RULES:
|
|
| 125 |
8. You may set field_path='HINT' for a location clue at -0.5 reward cost.
|
| 126 |
""")
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
def build_user_prompt(
|
| 130 |
observation: Dict[str, Any],
|
| 131 |
step: int,
|
| 132 |
history: List[str],
|
| 133 |
) -> str:
|
| 134 |
-
"""Build
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
violations_found = observation.get("violations_found", [])
|
| 136 |
-
found_summary = "None yet"
|
| 137 |
if violations_found:
|
| 138 |
-
found_lines = [
|
| 139 |
-
|
| 140 |
-
|
|
|
|
| 141 |
found_summary = "\n".join(found_lines)
|
| 142 |
-
|
| 143 |
-
|
| 144 |
|
| 145 |
return textwrap.dedent(f"""\
|
| 146 |
Step: {step}
|
| 147 |
-
Task: {
|
|
|
|
| 148 |
Instructions: {observation.get('task_description', '')}
|
| 149 |
|
| 150 |
API Specification:
|
|
@@ -171,10 +316,17 @@ Respond with a single JSON object for the next violation (or DONE).
|
|
| 171 |
# ---------------------------------------------------------------------------
|
| 172 |
|
| 173 |
|
| 174 |
-
|
| 175 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
-
Handles
|
|
|
|
| 178 |
"""
|
| 179 |
cleaned = text.strip()
|
| 180 |
if cleaned.startswith("```"):
|
|
@@ -182,60 +334,119 @@ def parse_llm_response(text: str) -> Dict[str, str]:
|
|
| 182 |
lines = [l for l in lines if not l.strip().startswith("```")]
|
| 183 |
cleaned = "\n".join(lines).strip()
|
| 184 |
|
| 185 |
-
_VALID_TYPES = {
|
| 186 |
-
"type_mismatch", "missing_required", "invalid_enum",
|
| 187 |
-
"format_error", "extra_field", "breaking_change", "cross_field_constraint",
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
try:
|
| 191 |
data = json.loads(cleaned)
|
| 192 |
-
field_path = str(data.get("field_path", "DONE"))
|
| 193 |
-
violation_type = str(data.get("violation_type", "unknown"))
|
| 194 |
-
|
| 195 |
-
# LLMs sometimes embed ":violation_type" inside field_path — strip it
|
| 196 |
-
if ":" in field_path:
|
| 197 |
-
parts = field_path.split(":")
|
| 198 |
-
# Only strip if the suffix looks like a violation type keyword
|
| 199 |
-
if any(vt in parts[-1].lower() for vt in _VALID_TYPES):
|
| 200 |
-
field_path = parts[0].strip()
|
| 201 |
-
|
| 202 |
-
return {
|
| 203 |
-
"field_path": field_path,
|
| 204 |
-
"violation_type": violation_type,
|
| 205 |
-
"description": str(data.get("description", "")),
|
| 206 |
-
"suggested_fix": str(data.get("suggested_fix", "")),
|
| 207 |
-
}
|
| 208 |
except json.JSONDecodeError:
|
| 209 |
-
# If the model just says "DONE" or similar
|
| 210 |
upper = cleaned.upper()
|
| 211 |
if "DONE" in upper:
|
| 212 |
-
return {
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
"
|
| 216 |
-
"
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
return {
|
| 219 |
-
"
|
| 220 |
-
"
|
| 221 |
-
"
|
| 222 |
-
"
|
| 223 |
}
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
def query_llm(
|
| 227 |
client: OpenAI,
|
| 228 |
observation: Dict[str, Any],
|
| 229 |
step: int,
|
| 230 |
history: List[str],
|
| 231 |
-
) -> Dict[str,
|
| 232 |
"""Send the current observation to the LLM and return a parsed action."""
|
|
|
|
|
|
|
| 233 |
user_prompt = build_user_prompt(observation, step, history)
|
|
|
|
| 234 |
try:
|
| 235 |
completion = client.chat.completions.create(
|
| 236 |
model=MODEL_NAME,
|
| 237 |
messages=[
|
| 238 |
-
{"role": "system", "content":
|
| 239 |
{"role": "user", "content": user_prompt},
|
| 240 |
],
|
| 241 |
temperature=TEMPERATURE,
|
|
@@ -246,12 +457,16 @@ def query_llm(
|
|
| 246 |
return parse_llm_response(raw_text)
|
| 247 |
except Exception as exc:
|
| 248 |
print(f"[DEBUG] LLM request failed: {exc}", flush=True)
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
"
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
|
| 257 |
# ---------------------------------------------------------------------------
|
|
@@ -263,30 +478,40 @@ async def run_single_task(
|
|
| 263 |
client: OpenAI,
|
| 264 |
env: ValidatorEnv,
|
| 265 |
task_name: str,
|
| 266 |
-
) ->
|
| 267 |
-
"""Run a single task episode and emit structured logs.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
max_steps = MAX_STEPS_PER_TASK.get(task_name, 15)
|
| 269 |
history: List[str] = []
|
| 270 |
rewards: List[float] = []
|
| 271 |
steps_taken = 0
|
| 272 |
-
score = 0.01
|
| 273 |
success = False
|
| 274 |
consecutive_failures = 0
|
| 275 |
last_failed_path = ""
|
|
|
|
| 276 |
|
| 277 |
log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
|
| 278 |
|
| 279 |
try:
|
| 280 |
result = await env.reset(task_name=task_name)
|
| 281 |
-
obs_dict =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
for step in range(1, max_steps + 1):
|
| 284 |
if result.done:
|
| 285 |
break
|
| 286 |
|
| 287 |
-
#
|
| 288 |
-
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
| 289 |
action_data = {
|
|
|
|
| 290 |
"field_path": "HINT",
|
| 291 |
"violation_type": "",
|
| 292 |
"description": "",
|
|
@@ -297,64 +522,85 @@ async def run_single_task(
|
|
| 297 |
else:
|
| 298 |
action_data = query_llm(client, obs_dict, step, history)
|
| 299 |
|
| 300 |
-
action =
|
| 301 |
-
field_path=action_data["field_path"],
|
| 302 |
-
violation_type=action_data["violation_type"],
|
| 303 |
-
description=action_data["description"],
|
| 304 |
-
suggested_fix=action_data["suggested_fix"],
|
| 305 |
-
)
|
| 306 |
-
|
| 307 |
result = await env.step(action)
|
| 308 |
-
obs_dict =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
reward = result.reward or 0.0
|
| 311 |
done = result.done
|
| 312 |
-
error = None
|
| 313 |
-
|
| 314 |
rewards.append(reward)
|
| 315 |
steps_taken = step
|
| 316 |
|
| 317 |
-
action_str =
|
| 318 |
-
log_step(step=step, action=action_str, reward=reward, done=done, error=
|
| 319 |
-
|
| 320 |
-
#
|
| 321 |
-
if
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
consecutive_failures += 1
|
| 324 |
else:
|
| 325 |
consecutive_failures = 1
|
| 326 |
-
last_failed_path =
|
| 327 |
else:
|
| 328 |
consecutive_failures = 0
|
| 329 |
last_failed_path = ""
|
| 330 |
|
| 331 |
history.append(
|
| 332 |
-
f"Step {step}: {action_str} → reward {reward:+.2f}
|
| 333 |
-
f"({'correct' if reward >= 1.0 else 'WRONG - do not retry this field' if reward < 0 else 'partial'})"
|
| 334 |
)
|
| 335 |
|
| 336 |
if done:
|
| 337 |
break
|
| 338 |
|
| 339 |
-
#
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
score = min(max(score, 0.01), 0.99)
|
| 350 |
success = score >= SUCCESS_SCORE_THRESHOLD
|
| 351 |
|
| 352 |
finally:
|
| 353 |
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
|
| 354 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
|
| 356 |
async def main() -> None:
|
| 357 |
-
"""Run the inference agent against all tasks."""
|
|
|
|
|
|
|
| 358 |
openai_client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 359 |
|
| 360 |
if LOCAL_IMAGE_NAME:
|
|
@@ -363,15 +609,29 @@ async def main() -> None:
|
|
| 363 |
env_url = os.getenv("ENV_BASE_URL", "http://localhost:7860")
|
| 364 |
env = ValidatorEnv(base_url=env_url)
|
| 365 |
|
|
|
|
| 366 |
try:
|
| 367 |
for task_name in TASKS:
|
| 368 |
-
await run_single_task(openai_client, env, task_name)
|
|
|
|
| 369 |
finally:
|
| 370 |
try:
|
| 371 |
await env.close()
|
| 372 |
except Exception as exc:
|
| 373 |
print(f"[DEBUG] env.close() error: {exc}", flush=True)
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
if __name__ == "__main__":
|
| 377 |
asyncio.run(main())
|
|
|
|
| 20 |
import json
|
| 21 |
import os
|
| 22 |
import textwrap
|
| 23 |
+
from pathlib import Path
|
| 24 |
from typing import Any, Dict, List, Optional
|
| 25 |
|
| 26 |
+
# Load .env file (if present) before reading any os.getenv values.
|
| 27 |
+
# .env is gitignored — keeps HF_TOKEN out of source control.
|
| 28 |
+
try:
|
| 29 |
+
from dotenv import load_dotenv
|
| 30 |
+
|
| 31 |
+
_ENV_FILE = Path(__file__).resolve().parent / ".env"
|
| 32 |
+
if _ENV_FILE.exists():
|
| 33 |
+
load_dotenv(_ENV_FILE)
|
| 34 |
+
except ImportError:
|
| 35 |
+
pass # python-dotenv not installed — fall back to OS env vars only
|
| 36 |
+
|
| 37 |
from openai import OpenAI
|
| 38 |
|
| 39 |
from client import ValidatorEnv
|
|
|
|
| 47 |
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 48 |
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 49 |
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 50 |
+
SCORES_OUT_PATH = os.getenv("SCORES_OUT_PATH") # e.g. baseline_scores.json
|
| 51 |
|
| 52 |
BENCHMARK = "api_contract_validator"
|
| 53 |
+
PHASE1_TASKS = [
|
| 54 |
"find_type_mismatches",
|
| 55 |
"validate_nested_objects",
|
| 56 |
"detect_breaking_changes",
|
|
|
|
| 58 |
"validate_cross_field_constraints",
|
| 59 |
"validate_auth_request",
|
| 60 |
]
|
| 61 |
+
PHASE2_TASKS = ["trace_downstream_blast_radius"]
|
| 62 |
+
PHASE3_TASKS = ["propose_backward_compat_fix"]
|
| 63 |
+
CASCADE_TASKS = ["multi_service_cascade_fix"]
|
| 64 |
+
TASKS = PHASE1_TASKS + PHASE2_TASKS + PHASE3_TASKS + CASCADE_TASKS
|
| 65 |
+
|
| 66 |
MAX_STEPS_PER_TASK = {
|
| 67 |
"find_type_mismatches": 10,
|
| 68 |
"validate_nested_objects": 15,
|
|
|
|
| 70 |
"validate_response_schema": 25,
|
| 71 |
"validate_cross_field_constraints": 18,
|
| 72 |
"validate_auth_request": 14,
|
| 73 |
+
"trace_downstream_blast_radius": 20,
|
| 74 |
+
"propose_backward_compat_fix": 25,
|
| 75 |
+
"multi_service_cascade_fix": 40,
|
| 76 |
}
|
| 77 |
MAX_CONSECUTIVE_FAILURES = 3 # stop retrying same field after this many -0.3 rewards
|
| 78 |
TEMPERATURE = 0.2
|
|
|
|
| 115 |
# Prompt construction
|
| 116 |
# ---------------------------------------------------------------------------
|
| 117 |
|
| 118 |
+
SYSTEM_PROMPT_PHASE1 = textwrap.dedent("""\
|
| 119 |
You are an expert API contract validator. You will be given an OpenAPI \
|
| 120 |
specification and an API payload. Your job is to find ALL violations in the \
|
| 121 |
payload that do not conform to the spec.
|
|
|
|
| 123 |
Each turn you must respond with EXACTLY one JSON object (no markdown, no \
|
| 124 |
explanation outside the JSON):
|
| 125 |
{
|
| 126 |
+
"action_type": "report_violation",
|
| 127 |
"field_path": "<dot-notation path to the violated field, or 'DONE' if finished>",
|
| 128 |
"violation_type": "<type_mismatch|missing_required|invalid_enum|format_error|extra_field|breaking_change|cross_field_constraint>",
|
| 129 |
"description": "<brief explanation of the violation>",
|
|
|
|
| 139 |
- missing_required: required field absent from payload
|
| 140 |
- invalid_enum: value not in the allowed enum list
|
| 141 |
- format_error: value violates format/pattern/min/max constraint
|
| 142 |
+
- breaking_change: API v1→v2 change that breaks existing clients
|
| 143 |
- cross_field_constraint: arithmetic/date/conditional rule across multiple fields
|
| 144 |
5. Do NOT repeat a violation already in 'Violations found so far'.
|
| 145 |
6. If last feedback was 'False positive' or negative reward, that field is WRONG — move to a different field.
|
|
|
|
| 147 |
8. You may set field_path='HINT' for a location clue at -0.5 reward cost.
|
| 148 |
""")
|
| 149 |
|
| 150 |
+
SYSTEM_PROMPT_PHASE2 = textwrap.dedent("""\
|
| 151 |
+
You are an enterprise API impact analyst. A producer microservice has made \
|
| 152 |
+
a breaking change to its API. You will see:
|
| 153 |
+
* the breaking change (`violation`)
|
| 154 |
+
* a list of consumer services with the fields each consumer depends on
|
| 155 |
+
|
| 156 |
+
Your job: identify EVERY downstream service whose contract is broken by \
|
| 157 |
+
the change. Submit a SINGLE action listing all affected consumers.
|
| 158 |
+
|
| 159 |
+
Respond with EXACTLY one JSON object:
|
| 160 |
+
{
|
| 161 |
+
"action_type": "trace_impact",
|
| 162 |
+
"affected_services": ["ServiceA", "ServiceB", ...],
|
| 163 |
+
"reasoning": "<why these services are impacted>"
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
STRICT RULES:
|
| 167 |
+
1. Include a service ONLY if its declared `fields_consumed` overlaps with the \
|
| 168 |
+
field affected by the breaking change.
|
| 169 |
+
2. For enum-narrowing changes, a consumer is affected only if it emits one \
|
| 170 |
+
of the removed values.
|
| 171 |
+
3. Do NOT include services that consume unrelated fields — false flags are \
|
| 172 |
+
penalised heavily.
|
| 173 |
+
4. Service names are case-insensitive but must match those in the consumer list.
|
| 174 |
+
""")
|
| 175 |
+
|
| 176 |
+
SYSTEM_PROMPT_PHASE3 = textwrap.dedent("""\
|
| 177 |
+
You are a senior platform engineer designing a backward-compatible \
|
| 178 |
+
migration. You see a breaking change and a list of consumer specs.
|
| 179 |
+
|
| 180 |
+
Your job: propose a fix (a `spec_patch`) that lets every consumer keep \
|
| 181 |
+
working without redeploying.
|
| 182 |
+
|
| 183 |
+
Respond with EXACTLY one JSON object:
|
| 184 |
+
{
|
| 185 |
+
"action_type": "propose_fix",
|
| 186 |
+
"fix_strategy": "<field_alias|version_bump|deprecation_window|dual_write|consumer_patch>",
|
| 187 |
+
"spec_patch": { ... },
|
| 188 |
+
"rationale": "<why this preserves backward compat>"
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
Strategy contracts (the patch must contain these keys):
|
| 192 |
+
* field_alias → spec_patch.aliases = { "<old_name>": "<new_name>", ... }
|
| 193 |
+
* version_bump → spec_patch.versions = ["v1.0", "v2.0"] (must keep both)
|
| 194 |
+
* deprecation_window → spec_patch.deprecated_fields = ["<old_field>"]
|
| 195 |
+
OR spec_patch.deprecated_enum_values = ["<old_val>"]
|
| 196 |
+
* dual_write → spec_patch.emit_fields = ["<old>", "<new>"]
|
| 197 |
+
* consumer_patch → spec_patch.consumers_to_migrate = [<every affected consumer>]
|
| 198 |
+
|
| 199 |
+
STRICT RULES:
|
| 200 |
+
1. Pick the strategy that fits the change. For enum narrowing, prefer \
|
| 201 |
+
consumer_patch or version_bump (aliasing cannot restore enum values).
|
| 202 |
+
2. The patch must apply for EVERY affected consumer, not just one.
|
| 203 |
+
3. If the proposal fails, refine the patch and try again — do not repeat \
|
| 204 |
+
the same failing patch.
|
| 205 |
+
""")
|
| 206 |
+
|
| 207 |
+
SYSTEM_PROMPT_CASCADE = SYSTEM_PROMPT_PHASE2 + "\n\n" + SYSTEM_PROMPT_PHASE3 + (
|
| 208 |
+
"\n\nThe episode begins in Phase 2 (trace_impact). After every consumer "
|
| 209 |
+
"is correctly traced, the environment switches to Phase 3 (propose_fix)."
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _system_prompt_for_phase(phase: str, task_name: str) -> str:
|
| 214 |
+
if task_name in CASCADE_TASKS:
|
| 215 |
+
return SYSTEM_PROMPT_CASCADE
|
| 216 |
+
if phase == "tracing" or task_name in PHASE2_TASKS:
|
| 217 |
+
return SYSTEM_PROMPT_PHASE2
|
| 218 |
+
if phase == "fix_proposal" or task_name in PHASE3_TASKS:
|
| 219 |
+
return SYSTEM_PROMPT_PHASE3
|
| 220 |
+
return SYSTEM_PROMPT_PHASE1
|
| 221 |
+
|
| 222 |
|
| 223 |
def build_user_prompt(
|
| 224 |
observation: Dict[str, Any],
|
| 225 |
step: int,
|
| 226 |
history: List[str],
|
| 227 |
) -> str:
|
| 228 |
+
"""Build a phase-aware user prompt from the current observation."""
|
| 229 |
+
phase = observation.get("phase", "detection")
|
| 230 |
+
task_name = observation.get("task_name", "")
|
| 231 |
+
history_block = "\n".join(history[-5:]) if history else "None"
|
| 232 |
+
|
| 233 |
+
if phase == "tracing" or task_name in PHASE2_TASKS:
|
| 234 |
+
graph = observation.get("service_graph", {})
|
| 235 |
+
return textwrap.dedent(f"""\
|
| 236 |
+
Step: {step}
|
| 237 |
+
Task: {task_name}
|
| 238 |
+
Phase: 2 — Impact Tracing
|
| 239 |
+
Instructions: {observation.get('task_description', '')}
|
| 240 |
+
|
| 241 |
+
Breaking change:
|
| 242 |
+
{json.dumps(graph.get('violation', observation.get('detected_violation', {})), indent=2)}
|
| 243 |
+
|
| 244 |
+
Consumers (each declares fields_consumed):
|
| 245 |
+
{json.dumps(graph.get('consumers', []), indent=2)}
|
| 246 |
+
|
| 247 |
+
Last feedback: {observation.get('feedback', '')}
|
| 248 |
+
Previous steps:
|
| 249 |
+
{history_block}
|
| 250 |
+
|
| 251 |
+
Respond with a single JSON object containing action_type='trace_impact'.
|
| 252 |
+
""")
|
| 253 |
+
|
| 254 |
+
if phase == "fix_proposal" or task_name in PHASE3_TASKS:
|
| 255 |
+
violation = observation.get("detected_violation", {})
|
| 256 |
+
consumer_specs = observation.get("consumer_specs", {})
|
| 257 |
+
last_results = observation.get("fix_validation_results", {})
|
| 258 |
+
return textwrap.dedent(f"""\
|
| 259 |
+
Step: {step}
|
| 260 |
+
Task: {task_name}
|
| 261 |
+
Phase: 3 — Fix & Verify
|
| 262 |
+
Instructions: {observation.get('task_description', '')}
|
| 263 |
+
|
| 264 |
+
Breaking change to fix:
|
| 265 |
+
{json.dumps(violation, indent=2)}
|
| 266 |
+
|
| 267 |
+
Consumer specs (every consumer must keep working):
|
| 268 |
+
{json.dumps(consumer_specs, indent=2)}
|
| 269 |
+
|
| 270 |
+
Last fix attempt result: {json.dumps(last_results, indent=2) if last_results else 'None'}
|
| 271 |
+
Last feedback: {observation.get('feedback', '')}
|
| 272 |
+
Previous steps:
|
| 273 |
+
{history_block}
|
| 274 |
+
|
| 275 |
+
Respond with a single JSON object containing action_type='propose_fix'.
|
| 276 |
+
""")
|
| 277 |
+
|
| 278 |
+
# Default: Phase 1 — Detection
|
| 279 |
violations_found = observation.get("violations_found", [])
|
|
|
|
| 280 |
if violations_found:
|
| 281 |
+
found_lines = [
|
| 282 |
+
f" - {v['field_path']}: {v['violation_type']}"
|
| 283 |
+
for v in violations_found
|
| 284 |
+
]
|
| 285 |
found_summary = "\n".join(found_lines)
|
| 286 |
+
else:
|
| 287 |
+
found_summary = "None yet"
|
| 288 |
|
| 289 |
return textwrap.dedent(f"""\
|
| 290 |
Step: {step}
|
| 291 |
+
Task: {task_name}
|
| 292 |
+
Phase: 1 — Detection
|
| 293 |
Instructions: {observation.get('task_description', '')}
|
| 294 |
|
| 295 |
API Specification:
|
|
|
|
| 316 |
# ---------------------------------------------------------------------------
|
| 317 |
|
| 318 |
|
| 319 |
+
_VALID_VIOLATION_TYPES = {
|
| 320 |
+
"type_mismatch", "missing_required", "invalid_enum",
|
| 321 |
+
"format_error", "extra_field", "breaking_change", "cross_field_constraint",
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def parse_llm_response(text: str) -> Dict[str, Any]:
|
| 326 |
+
"""Parse the LLM response into an action dict for any phase.
|
| 327 |
|
| 328 |
+
Handles markdown fences and infers the action_type when the model
|
| 329 |
+
omits it but includes phase-specific fields.
|
| 330 |
"""
|
| 331 |
cleaned = text.strip()
|
| 332 |
if cleaned.startswith("```"):
|
|
|
|
| 334 |
lines = [l for l in lines if not l.strip().startswith("```")]
|
| 335 |
cleaned = "\n".join(lines).strip()
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
try:
|
| 338 |
data = json.loads(cleaned)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
except json.JSONDecodeError:
|
|
|
|
| 340 |
upper = cleaned.upper()
|
| 341 |
if "DONE" in upper:
|
| 342 |
+
return {"action_type": "report_violation", "field_path": "DONE",
|
| 343 |
+
"violation_type": "", "description": "", "suggested_fix": ""}
|
| 344 |
+
return {"action_type": "report_violation", "field_path": "DONE",
|
| 345 |
+
"violation_type": "unknown",
|
| 346 |
+
"description": f"Failed to parse: {cleaned[:100]}",
|
| 347 |
+
"suggested_fix": ""}
|
| 348 |
+
|
| 349 |
+
action_type = str(data.get("action_type", "")).strip()
|
| 350 |
+
|
| 351 |
+
# Infer action_type when omitted
|
| 352 |
+
if not action_type:
|
| 353 |
+
if "affected_services" in data:
|
| 354 |
+
action_type = "trace_impact"
|
| 355 |
+
elif "fix_strategy" in data or "spec_patch" in data:
|
| 356 |
+
action_type = "propose_fix"
|
| 357 |
+
else:
|
| 358 |
+
action_type = "report_violation"
|
| 359 |
+
|
| 360 |
+
if action_type == "trace_impact":
|
| 361 |
+
services = data.get("affected_services") or []
|
| 362 |
+
if not isinstance(services, list):
|
| 363 |
+
services = [str(services)]
|
| 364 |
+
return {
|
| 365 |
+
"action_type": "trace_impact",
|
| 366 |
+
"affected_services": [str(s) for s in services],
|
| 367 |
+
"reasoning": str(data.get("reasoning", "")),
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
if action_type in ("propose_fix", "validate_fix"):
|
| 371 |
+
patch = data.get("spec_patch") or {}
|
| 372 |
+
if not isinstance(patch, dict):
|
| 373 |
+
patch = {}
|
| 374 |
return {
|
| 375 |
+
"action_type": action_type,
|
| 376 |
+
"fix_strategy": str(data.get("fix_strategy", "")),
|
| 377 |
+
"spec_patch": patch,
|
| 378 |
+
"rationale": str(data.get("rationale", "")),
|
| 379 |
}
|
| 380 |
|
| 381 |
+
# Default: Phase 1 — report_violation
|
| 382 |
+
field_path = str(data.get("field_path", "DONE"))
|
| 383 |
+
violation_type = str(data.get("violation_type", "unknown"))
|
| 384 |
+
if ":" in field_path:
|
| 385 |
+
parts = field_path.split(":")
|
| 386 |
+
if any(vt in parts[-1].lower() for vt in _VALID_VIOLATION_TYPES):
|
| 387 |
+
field_path = parts[0].strip()
|
| 388 |
+
return {
|
| 389 |
+
"action_type": "report_violation",
|
| 390 |
+
"field_path": field_path,
|
| 391 |
+
"violation_type": violation_type,
|
| 392 |
+
"description": str(data.get("description", "")),
|
| 393 |
+
"suggested_fix": str(data.get("suggested_fix", "")),
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _build_action(action_data: Dict[str, Any]) -> ValidatorAction:
|
| 398 |
+
"""Materialise a ValidatorAction from a parsed-LLM dict."""
|
| 399 |
+
at = action_data.get("action_type", "report_violation")
|
| 400 |
+
if at == "trace_impact":
|
| 401 |
+
return ValidatorAction(
|
| 402 |
+
action_type="trace_impact",
|
| 403 |
+
affected_services=action_data.get("affected_services", []),
|
| 404 |
+
reasoning=action_data.get("reasoning", ""),
|
| 405 |
+
)
|
| 406 |
+
if at in ("propose_fix", "validate_fix"):
|
| 407 |
+
return ValidatorAction(
|
| 408 |
+
action_type=at,
|
| 409 |
+
fix_strategy=action_data.get("fix_strategy", ""),
|
| 410 |
+
spec_patch=action_data.get("spec_patch", {}),
|
| 411 |
+
rationale=action_data.get("rationale", ""),
|
| 412 |
+
)
|
| 413 |
+
return ValidatorAction(
|
| 414 |
+
action_type="report_violation",
|
| 415 |
+
field_path=action_data.get("field_path", "DONE"),
|
| 416 |
+
violation_type=action_data.get("violation_type", ""),
|
| 417 |
+
description=action_data.get("description", ""),
|
| 418 |
+
suggested_fix=action_data.get("suggested_fix", ""),
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def _action_summary(action_data: Dict[str, Any]) -> str:
|
| 423 |
+
at = action_data.get("action_type", "report_violation")
|
| 424 |
+
if at == "trace_impact":
|
| 425 |
+
return f"trace_impact:{','.join(action_data.get('affected_services', []))}"
|
| 426 |
+
if at in ("propose_fix", "validate_fix"):
|
| 427 |
+
return f"{at}:{action_data.get('fix_strategy','')}"
|
| 428 |
+
return (
|
| 429 |
+
f"{action_data.get('field_path','')}:"
|
| 430 |
+
f"{action_data.get('violation_type','')}"
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
|
| 434 |
def query_llm(
|
| 435 |
client: OpenAI,
|
| 436 |
observation: Dict[str, Any],
|
| 437 |
step: int,
|
| 438 |
history: List[str],
|
| 439 |
+
) -> Dict[str, Any]:
|
| 440 |
"""Send the current observation to the LLM and return a parsed action."""
|
| 441 |
+
phase = observation.get("phase", "detection")
|
| 442 |
+
task_name = observation.get("task_name", "")
|
| 443 |
user_prompt = build_user_prompt(observation, step, history)
|
| 444 |
+
system_prompt = _system_prompt_for_phase(phase, task_name)
|
| 445 |
try:
|
| 446 |
completion = client.chat.completions.create(
|
| 447 |
model=MODEL_NAME,
|
| 448 |
messages=[
|
| 449 |
+
{"role": "system", "content": system_prompt},
|
| 450 |
{"role": "user", "content": user_prompt},
|
| 451 |
],
|
| 452 |
temperature=TEMPERATURE,
|
|
|
|
| 457 |
return parse_llm_response(raw_text)
|
| 458 |
except Exception as exc:
|
| 459 |
print(f"[DEBUG] LLM request failed: {exc}", flush=True)
|
| 460 |
+
# Safe fallback action for any phase
|
| 461 |
+
if phase == "tracing" or task_name in PHASE2_TASKS:
|
| 462 |
+
return {"action_type": "trace_impact", "affected_services": [],
|
| 463 |
+
"reasoning": f"LLM error: {exc}"}
|
| 464 |
+
if phase == "fix_proposal" or task_name in PHASE3_TASKS:
|
| 465 |
+
return {"action_type": "propose_fix", "fix_strategy": "",
|
| 466 |
+
"spec_patch": {}, "rationale": f"LLM error: {exc}"}
|
| 467 |
+
return {"action_type": "report_violation", "field_path": "DONE",
|
| 468 |
+
"violation_type": "", "description": f"LLM error: {exc}",
|
| 469 |
+
"suggested_fix": ""}
|
| 470 |
|
| 471 |
|
| 472 |
# ---------------------------------------------------------------------------
|
|
|
|
| 478 |
client: OpenAI,
|
| 479 |
env: ValidatorEnv,
|
| 480 |
task_name: str,
|
| 481 |
+
) -> Dict[str, Any]:
|
| 482 |
+
"""Run a single task episode and emit structured logs.
|
| 483 |
+
|
| 484 |
+
Returns a dict with the per-task summary so the caller can aggregate
|
| 485 |
+
a baseline_scores.json or trained_scores.json file.
|
| 486 |
+
"""
|
| 487 |
max_steps = MAX_STEPS_PER_TASK.get(task_name, 15)
|
| 488 |
history: List[str] = []
|
| 489 |
rewards: List[float] = []
|
| 490 |
steps_taken = 0
|
| 491 |
+
score = 0.01
|
| 492 |
success = False
|
| 493 |
consecutive_failures = 0
|
| 494 |
last_failed_path = ""
|
| 495 |
+
is_phase1_task = task_name in PHASE1_TASKS
|
| 496 |
|
| 497 |
log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
|
| 498 |
|
| 499 |
try:
|
| 500 |
result = await env.reset(task_name=task_name)
|
| 501 |
+
obs_dict = (
|
| 502 |
+
result.observation.model_dump()
|
| 503 |
+
if hasattr(result.observation, "model_dump")
|
| 504 |
+
else result.observation.__dict__
|
| 505 |
+
)
|
| 506 |
|
| 507 |
for step in range(1, max_steps + 1):
|
| 508 |
if result.done:
|
| 509 |
break
|
| 510 |
|
| 511 |
+
# Phase 1 only: trigger HINT after repeated failures on same field
|
| 512 |
+
if is_phase1_task and consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
| 513 |
action_data = {
|
| 514 |
+
"action_type": "report_violation",
|
| 515 |
"field_path": "HINT",
|
| 516 |
"violation_type": "",
|
| 517 |
"description": "",
|
|
|
|
| 522 |
else:
|
| 523 |
action_data = query_llm(client, obs_dict, step, history)
|
| 524 |
|
| 525 |
+
action = _build_action(action_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
result = await env.step(action)
|
| 527 |
+
obs_dict = (
|
| 528 |
+
result.observation.model_dump()
|
| 529 |
+
if hasattr(result.observation, "model_dump")
|
| 530 |
+
else result.observation.__dict__
|
| 531 |
+
)
|
| 532 |
|
| 533 |
reward = result.reward or 0.0
|
| 534 |
done = result.done
|
|
|
|
|
|
|
| 535 |
rewards.append(reward)
|
| 536 |
steps_taken = step
|
| 537 |
|
| 538 |
+
action_str = _action_summary(action_data)
|
| 539 |
+
log_step(step=step, action=action_str, reward=reward, done=done, error=None)
|
| 540 |
+
|
| 541 |
+
# Phase 1 only: track stuck-on-same-field
|
| 542 |
+
if (
|
| 543 |
+
is_phase1_task
|
| 544 |
+
and reward < 0
|
| 545 |
+
and action_data.get("field_path") not in ("DONE", "HINT")
|
| 546 |
+
):
|
| 547 |
+
fp = action_data.get("field_path", "")
|
| 548 |
+
if fp == last_failed_path:
|
| 549 |
consecutive_failures += 1
|
| 550 |
else:
|
| 551 |
consecutive_failures = 1
|
| 552 |
+
last_failed_path = fp
|
| 553 |
else:
|
| 554 |
consecutive_failures = 0
|
| 555 |
last_failed_path = ""
|
| 556 |
|
| 557 |
history.append(
|
| 558 |
+
f"Step {step}: {action_str} → reward {reward:+.2f}"
|
|
|
|
| 559 |
)
|
| 560 |
|
| 561 |
if done:
|
| 562 |
break
|
| 563 |
|
| 564 |
+
# Final score: trust env-side score (most accurate); fall back to
|
| 565 |
+
# Phase 1 heuristic for back-compat with older Phase 1 evaluators.
|
| 566 |
+
if is_phase1_task:
|
| 567 |
+
if rewards:
|
| 568 |
+
correct_count = sum(1 for r in rewards if r >= 1.0)
|
| 569 |
+
total_violations = obs_dict.get("violations_remaining", 0) + len(
|
| 570 |
+
obs_dict.get("violations_found", [])
|
| 571 |
+
)
|
| 572 |
+
score = (
|
| 573 |
+
correct_count / total_violations
|
| 574 |
+
if total_violations > 0
|
| 575 |
+
else 0.0
|
| 576 |
+
)
|
| 577 |
+
else:
|
| 578 |
+
# Phase 2/3 score is already computed by the environment.
|
| 579 |
+
try:
|
| 580 |
+
state = await env.state()
|
| 581 |
+
score = getattr(state, "score", None) or 0.01
|
| 582 |
+
except Exception:
|
| 583 |
+
score = 0.01
|
| 584 |
+
|
| 585 |
score = min(max(score, 0.01), 0.99)
|
| 586 |
success = score >= SUCCESS_SCORE_THRESHOLD
|
| 587 |
|
| 588 |
finally:
|
| 589 |
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
|
| 590 |
|
| 591 |
+
return {
|
| 592 |
+
"task": task_name,
|
| 593 |
+
"score": round(score, 4),
|
| 594 |
+
"steps": steps_taken,
|
| 595 |
+
"success": success,
|
| 596 |
+
"rewards": [round(r, 4) for r in rewards],
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
|
| 600 |
async def main() -> None:
|
| 601 |
+
"""Run the inference agent against all tasks and optionally save scores."""
|
| 602 |
+
from datetime import datetime, timezone
|
| 603 |
+
|
| 604 |
openai_client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 605 |
|
| 606 |
if LOCAL_IMAGE_NAME:
|
|
|
|
| 609 |
env_url = os.getenv("ENV_BASE_URL", "http://localhost:7860")
|
| 610 |
env = ValidatorEnv(base_url=env_url)
|
| 611 |
|
| 612 |
+
results: List[Dict[str, Any]] = []
|
| 613 |
try:
|
| 614 |
for task_name in TASKS:
|
| 615 |
+
res = await run_single_task(openai_client, env, task_name)
|
| 616 |
+
results.append(res)
|
| 617 |
finally:
|
| 618 |
try:
|
| 619 |
await env.close()
|
| 620 |
except Exception as exc:
|
| 621 |
print(f"[DEBUG] env.close() error: {exc}", flush=True)
|
| 622 |
|
| 623 |
+
if SCORES_OUT_PATH:
|
| 624 |
+
scores_obj = {
|
| 625 |
+
"model": MODEL_NAME,
|
| 626 |
+
"benchmark": BENCHMARK,
|
| 627 |
+
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
| 628 |
+
"scores": {r["task"]: r["score"] for r in results},
|
| 629 |
+
"details": results,
|
| 630 |
+
}
|
| 631 |
+
with open(SCORES_OUT_PATH, "w", encoding="utf-8") as fh:
|
| 632 |
+
json.dump(scores_obj, fh, indent=2)
|
| 633 |
+
print(f"[INFO] wrote {SCORES_OUT_PATH}", flush=True)
|
| 634 |
+
|
| 635 |
|
| 636 |
if __name__ == "__main__":
|
| 637 |
asyncio.run(main())
|
models.py
CHANGED
|
@@ -2,7 +2,11 @@
|
|
| 2 |
Data models for the API Contract Validator Environment.
|
| 3 |
|
| 4 |
Defines typed Action, Observation, and State models that form the
|
| 5 |
-
contract between the agent and the environment
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from typing import Any, Dict, List, Optional
|
|
@@ -11,19 +15,47 @@ from openenv.core.env_server.types import Action, Observation, State
|
|
| 11 |
from pydantic import Field
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
# ---------------------------------------------------------------------------
|
| 15 |
# Action — what the agent submits each step
|
| 16 |
# ---------------------------------------------------------------------------
|
| 17 |
|
| 18 |
class ValidatorAction(Action):
|
| 19 |
-
"""A single
|
| 20 |
|
| 21 |
-
The
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
"""
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
field_path: str = Field(
|
| 26 |
-
|
| 27 |
description=(
|
| 28 |
"Dot-notation path to the violated field, e.g. 'user.email'. "
|
| 29 |
"Use 'DONE' to signal no more violations. "
|
|
@@ -31,7 +63,7 @@ class ValidatorAction(Action):
|
|
| 31 |
),
|
| 32 |
)
|
| 33 |
violation_type: str = Field(
|
| 34 |
-
|
| 35 |
description=(
|
| 36 |
"Category of violation: type_mismatch | missing_required | "
|
| 37 |
"invalid_enum | format_error | extra_field | breaking_change | "
|
|
@@ -47,26 +79,68 @@ class ValidatorAction(Action):
|
|
| 47 |
description="Optional suggested correction for the violation.",
|
| 48 |
)
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
# ---------------------------------------------------------------------------
|
| 52 |
# Observation — what the agent sees after each step
|
| 53 |
# ---------------------------------------------------------------------------
|
| 54 |
|
| 55 |
class ValidatorObservation(Observation):
|
| 56 |
-
"""
|
| 57 |
|
| 58 |
Inherits ``done: bool`` and ``reward: Optional[float]`` from the
|
| 59 |
``Observation`` base class.
|
| 60 |
"""
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
description="Identifier for the current task.",
|
| 65 |
-
)
|
| 66 |
task_description: str = Field(
|
| 67 |
default="",
|
| 68 |
description="Natural-language instructions for the agent.",
|
| 69 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
api_spec: Dict[str, Any] = Field(
|
| 71 |
default_factory=dict,
|
| 72 |
description="The OpenAPI specification (or spec diff for hard tasks).",
|
|
@@ -83,13 +157,39 @@ class ValidatorObservation(Observation):
|
|
| 83 |
default=0,
|
| 84 |
description="Number of planted violations still undetected.",
|
| 85 |
)
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
)
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
default=0,
|
| 92 |
-
description="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
)
|
| 94 |
|
| 95 |
|
|
@@ -105,8 +205,23 @@ class ValidatorState(State):
|
|
| 105 |
"""
|
| 106 |
|
| 107 |
task_name: str = ""
|
|
|
|
|
|
|
|
|
|
| 108 |
total_violations: int = 0
|
| 109 |
correct_reports: int = 0
|
| 110 |
false_positives: int = 0
|
| 111 |
duplicate_reports: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
score: float = 0.01
|
|
|
|
| 2 |
Data models for the API Contract Validator Environment.
|
| 3 |
|
| 4 |
Defines typed Action, Observation, and State models that form the
|
| 5 |
+
contract between the agent and the environment across three phases:
|
| 6 |
+
|
| 7 |
+
Phase 1 — Detection action_type='report_violation'
|
| 8 |
+
Phase 2 — Impact Tracing action_type='trace_impact'
|
| 9 |
+
Phase 3 — Fix & Verify action_type='propose_fix' | 'validate_fix'
|
| 10 |
"""
|
| 11 |
|
| 12 |
from typing import Any, Dict, List, Optional
|
|
|
|
| 15 |
from pydantic import Field
|
| 16 |
|
| 17 |
|
| 18 |
+
# ── Action types ──────────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
ACTION_REPORT_VIOLATION = "report_violation"
|
| 21 |
+
ACTION_TRACE_IMPACT = "trace_impact"
|
| 22 |
+
ACTION_PROPOSE_FIX = "propose_fix"
|
| 23 |
+
ACTION_VALIDATE_FIX = "validate_fix"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
# ---------------------------------------------------------------------------
|
| 27 |
# Action — what the agent submits each step
|
| 28 |
# ---------------------------------------------------------------------------
|
| 29 |
|
| 30 |
class ValidatorAction(Action):
|
| 31 |
+
"""A single agent action.
|
| 32 |
|
| 33 |
+
The ``action_type`` field selects which phase the action belongs to:
|
| 34 |
+
|
| 35 |
+
* ``report_violation`` (Phase 1, default) — uses ``field_path`` and
|
| 36 |
+
``violation_type``. Special ``field_path`` values: ``DONE`` ends the
|
| 37 |
+
episode, ``HINT`` requests a location clue at -0.5 reward.
|
| 38 |
+
* ``trace_impact`` (Phase 2) — uses ``affected_services`` and
|
| 39 |
+
``reasoning``.
|
| 40 |
+
* ``propose_fix`` / ``validate_fix`` (Phase 3) — uses
|
| 41 |
+
``fix_strategy``, ``spec_patch``, ``rationale``.
|
| 42 |
+
|
| 43 |
+
All fields are optional so a single dataclass can carry every action
|
| 44 |
+
type. Phase 1 callers that only set ``field_path`` + ``violation_type``
|
| 45 |
+
continue to work without modification.
|
| 46 |
"""
|
| 47 |
|
| 48 |
+
action_type: str = Field(
|
| 49 |
+
default=ACTION_REPORT_VIOLATION,
|
| 50 |
+
description=(
|
| 51 |
+
"One of 'report_violation' (Phase 1), 'trace_impact' (Phase 2), "
|
| 52 |
+
"'propose_fix' (Phase 3), 'validate_fix' (Phase 3)."
|
| 53 |
+
),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# ── Phase 1 — detection ──────────────────────────────────────────
|
| 57 |
field_path: str = Field(
|
| 58 |
+
default="",
|
| 59 |
description=(
|
| 60 |
"Dot-notation path to the violated field, e.g. 'user.email'. "
|
| 61 |
"Use 'DONE' to signal no more violations. "
|
|
|
|
| 63 |
),
|
| 64 |
)
|
| 65 |
violation_type: str = Field(
|
| 66 |
+
default="",
|
| 67 |
description=(
|
| 68 |
"Category of violation: type_mismatch | missing_required | "
|
| 69 |
"invalid_enum | format_error | extra_field | breaking_change | "
|
|
|
|
| 79 |
description="Optional suggested correction for the violation.",
|
| 80 |
)
|
| 81 |
|
| 82 |
+
# ── Phase 2 — impact tracing ─────────────────────────────────────
|
| 83 |
+
affected_services: List[str] = Field(
|
| 84 |
+
default_factory=list,
|
| 85 |
+
description=(
|
| 86 |
+
"Phase 2 — names of downstream services the agent believes "
|
| 87 |
+
"are impacted by the breaking change."
|
| 88 |
+
),
|
| 89 |
+
)
|
| 90 |
+
reasoning: str = Field(
|
| 91 |
+
default="",
|
| 92 |
+
description="Phase 2 — brief justification for the impact assessment.",
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# ── Phase 3 — fix & verify ───────────────────────────────────────
|
| 96 |
+
fix_strategy: str = Field(
|
| 97 |
+
default="",
|
| 98 |
+
description=(
|
| 99 |
+
"Phase 3 — one of: field_alias | version_bump | "
|
| 100 |
+
"deprecation_window | dual_write | consumer_patch."
|
| 101 |
+
),
|
| 102 |
+
)
|
| 103 |
+
spec_patch: Dict[str, Any] = Field(
|
| 104 |
+
default_factory=dict,
|
| 105 |
+
description="Phase 3 — JSON-shaped patch to apply to the producer spec.",
|
| 106 |
+
)
|
| 107 |
+
rationale: str = Field(
|
| 108 |
+
default="",
|
| 109 |
+
description="Phase 3 — why the proposed fix preserves backward compatibility.",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
|
| 113 |
# ---------------------------------------------------------------------------
|
| 114 |
# Observation — what the agent sees after each step
|
| 115 |
# ---------------------------------------------------------------------------
|
| 116 |
|
| 117 |
class ValidatorObservation(Observation):
|
| 118 |
+
"""Environment response after each agent action.
|
| 119 |
|
| 120 |
Inherits ``done: bool`` and ``reward: Optional[float]`` from the
|
| 121 |
``Observation`` base class.
|
| 122 |
"""
|
| 123 |
|
| 124 |
+
# ── universal ────────────────────────────────────────────────────
|
| 125 |
+
task_name: str = Field(default="", description="Current task identifier.")
|
|
|
|
|
|
|
| 126 |
task_description: str = Field(
|
| 127 |
default="",
|
| 128 |
description="Natural-language instructions for the agent.",
|
| 129 |
)
|
| 130 |
+
phase: str = Field(
|
| 131 |
+
default="detection",
|
| 132 |
+
description="Current episode phase: detection | tracing | fix_proposal.",
|
| 133 |
+
)
|
| 134 |
+
feedback: str = Field(
|
| 135 |
+
default="",
|
| 136 |
+
description="Result of the last submitted action.",
|
| 137 |
+
)
|
| 138 |
+
max_steps: int = Field(
|
| 139 |
+
default=0,
|
| 140 |
+
description="Maximum steps allowed for the current episode.",
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# ── Phase 1 — detection ──────────────────────────────────────────
|
| 144 |
api_spec: Dict[str, Any] = Field(
|
| 145 |
default_factory=dict,
|
| 146 |
description="The OpenAPI specification (or spec diff for hard tasks).",
|
|
|
|
| 157 |
default=0,
|
| 158 |
description="Number of planted violations still undetected.",
|
| 159 |
)
|
| 160 |
+
|
| 161 |
+
# ── Phase 2 — impact tracing ─────────────────────────────────────
|
| 162 |
+
service_graph: Dict[str, Any] = Field(
|
| 163 |
+
default_factory=dict,
|
| 164 |
+
description=(
|
| 165 |
+
"Phase 2 — enterprise service graph: {producer: spec, "
|
| 166 |
+
"consumers: {name: {spec_excerpt, fields_consumed}}}."
|
| 167 |
+
),
|
| 168 |
)
|
| 169 |
+
consumers_traced: List[str] = Field(
|
| 170 |
+
default_factory=list,
|
| 171 |
+
description="Phase 2 — affected services the agent has correctly identified.",
|
| 172 |
+
)
|
| 173 |
+
total_consumers: int = Field(
|
| 174 |
default=0,
|
| 175 |
+
description="Phase 2 — total number of services in the graph.",
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# ── Phase 3 — fix & verify ───────────────────────────────────────
|
| 179 |
+
detected_violation: Dict[str, Any] = Field(
|
| 180 |
+
default_factory=dict,
|
| 181 |
+
description="Phase 3 — the breaking change that needs a fix.",
|
| 182 |
+
)
|
| 183 |
+
consumer_specs: Dict[str, Any] = Field(
|
| 184 |
+
default_factory=dict,
|
| 185 |
+
description="Phase 3 — consumer specs to validate the fix against.",
|
| 186 |
+
)
|
| 187 |
+
fix_validation_results: Dict[str, Any] = Field(
|
| 188 |
+
default_factory=dict,
|
| 189 |
+
description=(
|
| 190 |
+
"Phase 3 — per-consumer validation results from the last "
|
| 191 |
+
"validate_fix call."
|
| 192 |
+
),
|
| 193 |
)
|
| 194 |
|
| 195 |
|
|
|
|
| 205 |
"""
|
| 206 |
|
| 207 |
task_name: str = ""
|
| 208 |
+
phase: str = "detection"
|
| 209 |
+
|
| 210 |
+
# Phase 1
|
| 211 |
total_violations: int = 0
|
| 212 |
correct_reports: int = 0
|
| 213 |
false_positives: int = 0
|
| 214 |
duplicate_reports: int = 0
|
| 215 |
+
|
| 216 |
+
# Phase 2
|
| 217 |
+
total_consumers: int = 0
|
| 218 |
+
consumers_correctly_traced: int = 0
|
| 219 |
+
consumers_missed: int = 0
|
| 220 |
+
consumers_false_flagged: int = 0
|
| 221 |
+
|
| 222 |
+
# Phase 3
|
| 223 |
+
fix_attempts: int = 0
|
| 224 |
+
fix_validated: bool = False
|
| 225 |
+
fix_breaks_consumers: int = 0
|
| 226 |
+
|
| 227 |
score: float = 0.01
|
pyproject.toml
CHANGED
|
@@ -10,6 +10,7 @@ requires-python = ">=3.10"
|
|
| 10 |
dependencies = [
|
| 11 |
"openenv-core[core]>=0.2.2",
|
| 12 |
"openai>=1.0.0",
|
|
|
|
| 13 |
]
|
| 14 |
|
| 15 |
[project.optional-dependencies]
|
|
|
|
| 10 |
dependencies = [
|
| 11 |
"openenv-core[core]>=0.2.2",
|
| 12 |
"openai>=1.0.0",
|
| 13 |
+
"python-dotenv>=1.0.0",
|
| 14 |
]
|
| 15 |
|
| 16 |
[project.optional-dependencies]
|
results/.gitkeep
ADDED
|
File without changes
|
results/baseline_table.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
| Task | Score | Steps | Success |
|
| 2 |
+
|---|---|---|---|
|
| 3 |
+
| `find_type_mismatches` | 0.75 | 4 | ✅ |
|
| 4 |
+
| `validate_nested_objects` | 0.99 | 12 | ✅ |
|
| 5 |
+
| `detect_breaking_changes` | 0.01 | 20 | ⛔ |
|
| 6 |
+
| `validate_response_schema` | 0.99 | 10 | ✅ |
|
| 7 |
+
| `validate_cross_field_constraints` | 0.86 | 8 | ✅ |
|
| 8 |
+
| `validate_auth_request` | 0.99 | 10 | ✅ |
|
| 9 |
+
| `trace_downstream_blast_radius` | 0.67 | 1 | ✅ |
|
| 10 |
+
| `propose_backward_compat_fix` | 0.99 | 1 | ✅ |
|
| 11 |
+
| `multi_service_cascade_fix` | 0.99 | 2 | ✅ |
|
server/app.py
CHANGED
|
@@ -23,13 +23,16 @@ except Exception as exc:
|
|
| 23 |
try:
|
| 24 |
from ..models import ValidatorAction, ValidatorObservation
|
| 25 |
from .environment import ValidatorEnvironment
|
|
|
|
| 26 |
except (ImportError, ModuleNotFoundError):
|
| 27 |
import sys
|
| 28 |
import os
|
| 29 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 30 |
from models import ValidatorAction, ValidatorObservation
|
| 31 |
from server.environment import ValidatorEnvironment
|
|
|
|
| 32 |
|
|
|
|
| 33 |
|
| 34 |
app = create_app(
|
| 35 |
ValidatorEnvironment,
|
|
|
|
| 23 |
try:
|
| 24 |
from ..models import ValidatorAction, ValidatorObservation
|
| 25 |
from .environment import ValidatorEnvironment
|
| 26 |
+
from .logging_setup import configure_logging
|
| 27 |
except (ImportError, ModuleNotFoundError):
|
| 28 |
import sys
|
| 29 |
import os
|
| 30 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 31 |
from models import ValidatorAction, ValidatorObservation
|
| 32 |
from server.environment import ValidatorEnvironment
|
| 33 |
+
from server.logging_setup import configure_logging
|
| 34 |
|
| 35 |
+
configure_logging()
|
| 36 |
|
| 37 |
app = create_app(
|
| 38 |
ValidatorEnvironment,
|
server/environment.py
CHANGED
|
@@ -11,24 +11,58 @@ Special field_path values:
|
|
| 11 |
'HINT' — receive a location hint (costs -0.5 reward)
|
| 12 |
"""
|
| 13 |
|
|
|
|
|
|
|
|
|
|
| 14 |
from typing import Any, Dict, List, Optional, Set
|
| 15 |
from uuid import uuid4
|
| 16 |
|
| 17 |
from openenv.core.env_server.interfaces import Environment
|
| 18 |
from openenv.core.env_server.types import State
|
| 19 |
|
|
|
|
|
|
|
| 20 |
try:
|
| 21 |
-
from ..models import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
except (ImportError, ModuleNotFoundError):
|
| 23 |
import sys
|
| 24 |
import os
|
| 25 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 26 |
-
from models import
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
from .rewards import (
|
| 29 |
RewardBreakdown,
|
| 30 |
compute_episode_score,
|
| 31 |
compute_step_reward,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
)
|
| 33 |
from .spec_generator import (
|
| 34 |
AVAILABLE_TASKS,
|
|
@@ -38,6 +72,23 @@ from .spec_generator import (
|
|
| 38 |
)
|
| 39 |
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
def _normalise_path(path: str) -> str:
|
| 42 |
"""Lower-case and strip whitespace for fuzzy path matching."""
|
| 43 |
return path.strip().lower().replace(" ", "")
|
|
@@ -120,6 +171,10 @@ def _hint_section(field_path: str) -> str:
|
|
| 120 |
return path
|
| 121 |
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
class ValidatorEnvironment(Environment):
|
| 124 |
"""API Contract Validator — an OpenEnv RL environment.
|
| 125 |
|
|
@@ -150,6 +205,13 @@ class ValidatorEnvironment(Environment):
|
|
| 150 |
self._reported_violations: List[Dict[str, str]] = []
|
| 151 |
self._task_index: int = 0
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
# ── reset ─────────────────────────────────────────────────────────
|
| 154 |
|
| 155 |
def reset(
|
|
@@ -160,23 +222,51 @@ class ValidatorEnvironment(Environment):
|
|
| 160 |
) -> ValidatorObservation:
|
| 161 |
"""Start a new episode.
|
| 162 |
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
"""
|
| 166 |
task_name = kwargs.get("task_name") or AVAILABLE_TASKS[
|
| 167 |
self._task_index % len(AVAILABLE_TASKS)
|
| 168 |
]
|
| 169 |
self._task_index += 1
|
| 170 |
|
| 171 |
-
|
| 172 |
self._matched_paths = set()
|
| 173 |
self._proximity_paths = set()
|
| 174 |
self._reported_violations = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
self._state = ValidatorState(
|
| 177 |
episode_id=episode_id or str(uuid4()),
|
| 178 |
step_count=0,
|
| 179 |
task_name=self._scenario.task_name,
|
|
|
|
| 180 |
total_violations=len(self._scenario.violations),
|
| 181 |
correct_reports=0,
|
| 182 |
false_positives=0,
|
|
@@ -184,11 +274,22 @@ class ValidatorEnvironment(Environment):
|
|
| 184 |
score=0.0,
|
| 185 |
)
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
return ValidatorObservation(
|
| 188 |
done=False,
|
| 189 |
reward=0.0,
|
| 190 |
task_name=self._scenario.task_name,
|
| 191 |
task_description=self._scenario.task_description,
|
|
|
|
| 192 |
api_spec=self._scenario.api_spec,
|
| 193 |
payload=self._scenario.payload,
|
| 194 |
violations_found=[],
|
|
@@ -197,6 +298,183 @@ class ValidatorEnvironment(Environment):
|
|
| 197 |
max_steps=self._scenario.max_steps,
|
| 198 |
)
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
# ── step ──────────────────────────────────────────────────────────
|
| 201 |
|
| 202 |
def step(
|
|
@@ -205,10 +483,35 @@ class ValidatorEnvironment(Environment):
|
|
| 205 |
timeout_s: Optional[float] = None,
|
| 206 |
**kwargs: Any,
|
| 207 |
) -> ValidatorObservation:
|
| 208 |
-
"""
|
| 209 |
-
if self._scenario is None:
|
| 210 |
raise RuntimeError("Call reset() before step().")
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
self._state.step_count += 1
|
| 213 |
signal = action.field_path.strip().upper()
|
| 214 |
|
|
@@ -401,6 +704,223 @@ class ValidatorEnvironment(Environment):
|
|
| 401 |
|
| 402 |
# ── helpers ────────────────────────────────────────────────────────
|
| 403 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
def _build_observation(
|
| 405 |
self,
|
| 406 |
*,
|
|
@@ -411,11 +931,39 @@ class ValidatorEnvironment(Environment):
|
|
| 411 |
"""Construct an observation from current state."""
|
| 412 |
assert self._scenario is not None
|
| 413 |
remaining = self._state.total_violations - self._state.correct_reports
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
return ValidatorObservation(
|
| 415 |
done=done,
|
| 416 |
reward=reward,
|
| 417 |
task_name=self._scenario.task_name,
|
| 418 |
task_description=self._scenario.task_description,
|
|
|
|
| 419 |
api_spec=self._scenario.api_spec,
|
| 420 |
payload=self._scenario.payload,
|
| 421 |
violations_found=list(self._reported_violations),
|
|
|
|
| 11 |
'HINT' — receive a location hint (costs -0.5 reward)
|
| 12 |
"""
|
| 13 |
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
from typing import Any, Dict, List, Optional, Set
|
| 18 |
from uuid import uuid4
|
| 19 |
|
| 20 |
from openenv.core.env_server.interfaces import Environment
|
| 21 |
from openenv.core.env_server.types import State
|
| 22 |
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
try:
|
| 26 |
+
from ..models import (
|
| 27 |
+
ACTION_PROPOSE_FIX,
|
| 28 |
+
ACTION_REPORT_VIOLATION,
|
| 29 |
+
ACTION_TRACE_IMPACT,
|
| 30 |
+
ACTION_VALIDATE_FIX,
|
| 31 |
+
ValidatorAction,
|
| 32 |
+
ValidatorObservation,
|
| 33 |
+
ValidatorState,
|
| 34 |
+
)
|
| 35 |
except (ImportError, ModuleNotFoundError):
|
| 36 |
import sys
|
| 37 |
import os
|
| 38 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 39 |
+
from models import (
|
| 40 |
+
ACTION_PROPOSE_FIX,
|
| 41 |
+
ACTION_REPORT_VIOLATION,
|
| 42 |
+
ACTION_TRACE_IMPACT,
|
| 43 |
+
ACTION_VALIDATE_FIX,
|
| 44 |
+
ValidatorAction,
|
| 45 |
+
ValidatorObservation,
|
| 46 |
+
ValidatorState,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
from .fix_validator import validate_fix
|
| 50 |
+
from .impact_tracer import trace_impact
|
| 51 |
from .rewards import (
|
| 52 |
RewardBreakdown,
|
| 53 |
compute_episode_score,
|
| 54 |
compute_step_reward,
|
| 55 |
+
phase2_episode_score,
|
| 56 |
+
phase2_trace_rubric,
|
| 57 |
+
phase3_episode_score,
|
| 58 |
+
phase3_fix_rubric,
|
| 59 |
+
)
|
| 60 |
+
from .service_graph import (
|
| 61 |
+
CASCADE_SCENARIO_IDS,
|
| 62 |
+
CascadeScenario,
|
| 63 |
+
consumer_specs_for_fix,
|
| 64 |
+
get_cascade_scenario,
|
| 65 |
+
public_observation,
|
| 66 |
)
|
| 67 |
from .spec_generator import (
|
| 68 |
AVAILABLE_TASKS,
|
|
|
|
| 72 |
)
|
| 73 |
|
| 74 |
|
| 75 |
+
# ── Phase 2 / Phase 3 task names ─────────────────────────────────────────
|
| 76 |
+
|
| 77 |
+
PHASE2_TASKS: Set[str] = {"trace_downstream_blast_radius"}
|
| 78 |
+
PHASE3_TASKS: Set[str] = {"propose_backward_compat_fix"}
|
| 79 |
+
CASCADE_TASKS: Set[str] = {"multi_service_cascade_fix"}
|
| 80 |
+
ALL_TASKS: List[str] = (
|
| 81 |
+
AVAILABLE_TASKS
|
| 82 |
+
+ sorted(PHASE2_TASKS)
|
| 83 |
+
+ sorted(PHASE3_TASKS)
|
| 84 |
+
+ sorted(CASCADE_TASKS)
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
PHASE_DETECTION = "detection"
|
| 88 |
+
PHASE_TRACING = "tracing"
|
| 89 |
+
PHASE_FIX = "fix_proposal"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
def _normalise_path(path: str) -> str:
|
| 93 |
"""Lower-case and strip whitespace for fuzzy path matching."""
|
| 94 |
return path.strip().lower().replace(" ", "")
|
|
|
|
| 171 |
return path
|
| 172 |
|
| 173 |
|
| 174 |
+
def _now() -> str:
|
| 175 |
+
return datetime.now(timezone.utc).isoformat()
|
| 176 |
+
|
| 177 |
+
|
| 178 |
class ValidatorEnvironment(Environment):
|
| 179 |
"""API Contract Validator — an OpenEnv RL environment.
|
| 180 |
|
|
|
|
| 205 |
self._reported_violations: List[Dict[str, str]] = []
|
| 206 |
self._task_index: int = 0
|
| 207 |
|
| 208 |
+
# Phase 2 / Phase 3 episode state
|
| 209 |
+
self._cascade: Optional[CascadeScenario] = None
|
| 210 |
+
self._phase: str = PHASE_DETECTION
|
| 211 |
+
self._consumers_traced: Set[str] = set()
|
| 212 |
+
self._last_fix_results: Dict[str, Any] = {}
|
| 213 |
+
self._cascade_max_steps: int = 0
|
| 214 |
+
|
| 215 |
# ── reset ─────────────────────────────────────────────────────────
|
| 216 |
|
| 217 |
def reset(
|
|
|
|
| 222 |
) -> ValidatorObservation:
|
| 223 |
"""Start a new episode.
|
| 224 |
|
| 225 |
+
Dispatches to the right setup path based on ``task_name``:
|
| 226 |
+
|
| 227 |
+
* Phase 1 detection tasks (default) → spec + payload
|
| 228 |
+
* Phase 2 trace task → service graph + breaking change
|
| 229 |
+
* Phase 3 fix task → detected violation + consumer specs
|
| 230 |
+
* Cascade task → all three phases in one episode
|
| 231 |
"""
|
| 232 |
task_name = kwargs.get("task_name") or AVAILABLE_TASKS[
|
| 233 |
self._task_index % len(AVAILABLE_TASKS)
|
| 234 |
]
|
| 235 |
self._task_index += 1
|
| 236 |
|
| 237 |
+
# Reset shared episode bookkeeping
|
| 238 |
self._matched_paths = set()
|
| 239 |
self._proximity_paths = set()
|
| 240 |
self._reported_violations = []
|
| 241 |
+
self._consumers_traced = set()
|
| 242 |
+
self._last_fix_results = {}
|
| 243 |
+
self._cascade = None
|
| 244 |
+
self._scenario = None
|
| 245 |
+
|
| 246 |
+
if task_name in PHASE2_TASKS:
|
| 247 |
+
return self._reset_phase2(task_name, seed, episode_id)
|
| 248 |
+
if task_name in PHASE3_TASKS:
|
| 249 |
+
return self._reset_phase3(task_name, seed, episode_id)
|
| 250 |
+
if task_name in CASCADE_TASKS:
|
| 251 |
+
return self._reset_cascade(task_name, seed, episode_id)
|
| 252 |
+
return self._reset_phase1(task_name, seed, episode_id)
|
| 253 |
+
|
| 254 |
+
# ── Phase 1 reset (unchanged behaviour) ──────────────────────────
|
| 255 |
+
|
| 256 |
+
def _reset_phase1(
|
| 257 |
+
self,
|
| 258 |
+
task_name: str,
|
| 259 |
+
seed: Optional[int],
|
| 260 |
+
episode_id: Optional[str],
|
| 261 |
+
) -> ValidatorObservation:
|
| 262 |
+
self._phase = PHASE_DETECTION
|
| 263 |
+
self._scenario = generate_scenario_for_task(task_name, seed=seed)
|
| 264 |
|
| 265 |
self._state = ValidatorState(
|
| 266 |
episode_id=episode_id or str(uuid4()),
|
| 267 |
step_count=0,
|
| 268 |
task_name=self._scenario.task_name,
|
| 269 |
+
phase=PHASE_DETECTION,
|
| 270 |
total_violations=len(self._scenario.violations),
|
| 271 |
correct_reports=0,
|
| 272 |
false_positives=0,
|
|
|
|
| 274 |
score=0.0,
|
| 275 |
)
|
| 276 |
|
| 277 |
+
logger.info(json.dumps({
|
| 278 |
+
"event": "episode_start",
|
| 279 |
+
"episode_id": self._state.episode_id,
|
| 280 |
+
"task": self._state.task_name,
|
| 281 |
+
"phase": self._phase,
|
| 282 |
+
"total_violations": self._state.total_violations,
|
| 283 |
+
"max_steps": self._scenario.max_steps,
|
| 284 |
+
"ts": _now(),
|
| 285 |
+
}))
|
| 286 |
+
|
| 287 |
return ValidatorObservation(
|
| 288 |
done=False,
|
| 289 |
reward=0.0,
|
| 290 |
task_name=self._scenario.task_name,
|
| 291 |
task_description=self._scenario.task_description,
|
| 292 |
+
phase=PHASE_DETECTION,
|
| 293 |
api_spec=self._scenario.api_spec,
|
| 294 |
payload=self._scenario.payload,
|
| 295 |
violations_found=[],
|
|
|
|
| 298 |
max_steps=self._scenario.max_steps,
|
| 299 |
)
|
| 300 |
|
| 301 |
+
# ── Phase 2 reset — impact tracing ───────────────────────────────
|
| 302 |
+
|
| 303 |
+
def _reset_phase2(
|
| 304 |
+
self,
|
| 305 |
+
task_name: str,
|
| 306 |
+
seed: Optional[int],
|
| 307 |
+
episode_id: Optional[str],
|
| 308 |
+
) -> ValidatorObservation:
|
| 309 |
+
self._phase = PHASE_TRACING
|
| 310 |
+
self._cascade = get_cascade_scenario(seed=seed)
|
| 311 |
+
max_steps = 20
|
| 312 |
+
self._cascade_max_steps = max_steps
|
| 313 |
+
|
| 314 |
+
self._state = ValidatorState(
|
| 315 |
+
episode_id=episode_id or str(uuid4()),
|
| 316 |
+
step_count=0,
|
| 317 |
+
task_name=task_name,
|
| 318 |
+
phase=PHASE_TRACING,
|
| 319 |
+
total_consumers=len(self._cascade.consumers),
|
| 320 |
+
consumers_correctly_traced=0,
|
| 321 |
+
consumers_missed=len(self._cascade.ground_truth_affected),
|
| 322 |
+
consumers_false_flagged=0,
|
| 323 |
+
score=0.01,
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
logger.info(json.dumps({
|
| 327 |
+
"event": "episode_start",
|
| 328 |
+
"episode_id": self._state.episode_id,
|
| 329 |
+
"task": task_name,
|
| 330 |
+
"phase": self._phase,
|
| 331 |
+
"scenario": self._cascade.scenario_id,
|
| 332 |
+
"consumers": [c.name for c in self._cascade.consumers],
|
| 333 |
+
"max_steps": max_steps,
|
| 334 |
+
"ts": _now(),
|
| 335 |
+
}))
|
| 336 |
+
|
| 337 |
+
return ValidatorObservation(
|
| 338 |
+
done=False,
|
| 339 |
+
reward=0.0,
|
| 340 |
+
task_name=task_name,
|
| 341 |
+
task_description=(
|
| 342 |
+
f"{self._cascade.description} Submit a single trace_impact "
|
| 343 |
+
f"action listing every downstream service whose contract is "
|
| 344 |
+
f"broken by the change."
|
| 345 |
+
),
|
| 346 |
+
phase=PHASE_TRACING,
|
| 347 |
+
service_graph=public_observation(self._cascade),
|
| 348 |
+
consumers_traced=[],
|
| 349 |
+
total_consumers=len(self._cascade.consumers),
|
| 350 |
+
feedback=(
|
| 351 |
+
"Phase 2 — Impact Tracing. Inspect the service graph and "
|
| 352 |
+
"submit action_type='trace_impact' with affected_services."
|
| 353 |
+
),
|
| 354 |
+
max_steps=max_steps,
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
# ── Phase 3 reset — fix proposal ─────────────────────────────────
|
| 358 |
+
|
| 359 |
+
def _reset_phase3(
|
| 360 |
+
self,
|
| 361 |
+
task_name: str,
|
| 362 |
+
seed: Optional[int],
|
| 363 |
+
episode_id: Optional[str],
|
| 364 |
+
) -> ValidatorObservation:
|
| 365 |
+
self._phase = PHASE_FIX
|
| 366 |
+
self._cascade = get_cascade_scenario(seed=seed)
|
| 367 |
+
max_steps = 25
|
| 368 |
+
self._cascade_max_steps = max_steps
|
| 369 |
+
|
| 370 |
+
self._state = ValidatorState(
|
| 371 |
+
episode_id=episode_id or str(uuid4()),
|
| 372 |
+
step_count=0,
|
| 373 |
+
task_name=task_name,
|
| 374 |
+
phase=PHASE_FIX,
|
| 375 |
+
total_consumers=len(self._cascade.consumers),
|
| 376 |
+
fix_attempts=0,
|
| 377 |
+
fix_validated=False,
|
| 378 |
+
score=0.01,
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
logger.info(json.dumps({
|
| 382 |
+
"event": "episode_start",
|
| 383 |
+
"episode_id": self._state.episode_id,
|
| 384 |
+
"task": task_name,
|
| 385 |
+
"phase": self._phase,
|
| 386 |
+
"scenario": self._cascade.scenario_id,
|
| 387 |
+
"acceptable_strategies": self._cascade.acceptable_fix_strategies,
|
| 388 |
+
"max_steps": max_steps,
|
| 389 |
+
"ts": _now(),
|
| 390 |
+
}))
|
| 391 |
+
|
| 392 |
+
return ValidatorObservation(
|
| 393 |
+
done=False,
|
| 394 |
+
reward=0.0,
|
| 395 |
+
task_name=task_name,
|
| 396 |
+
task_description=(
|
| 397 |
+
f"{self._cascade.description} Submit propose_fix with a "
|
| 398 |
+
f"fix_strategy and spec_patch that keeps every consumer "
|
| 399 |
+
f"working."
|
| 400 |
+
),
|
| 401 |
+
phase=PHASE_FIX,
|
| 402 |
+
detected_violation=self._cascade.violation,
|
| 403 |
+
consumer_specs=consumer_specs_for_fix(self._cascade),
|
| 404 |
+
service_graph=public_observation(self._cascade),
|
| 405 |
+
feedback=(
|
| 406 |
+
"Phase 3 — Fix & Verify. Submit action_type='propose_fix' "
|
| 407 |
+
f"with fix_strategy in "
|
| 408 |
+
f"{self._cascade.acceptable_fix_strategies} and a "
|
| 409 |
+
"spec_patch object."
|
| 410 |
+
),
|
| 411 |
+
max_steps=max_steps,
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
# ── Cascade reset — full workflow ────────────────────────────────
|
| 415 |
+
|
| 416 |
+
def _reset_cascade(
|
| 417 |
+
self,
|
| 418 |
+
task_name: str,
|
| 419 |
+
seed: Optional[int],
|
| 420 |
+
episode_id: Optional[str],
|
| 421 |
+
) -> ValidatorObservation:
|
| 422 |
+
"""Full detect → trace → fix workflow in one episode.
|
| 423 |
+
|
| 424 |
+
Starts in tracing phase since the violation is given to the agent
|
| 425 |
+
upfront (cascade scenarios already include the breaking change).
|
| 426 |
+
Phase 3 begins after the agent submits a successful trace_impact.
|
| 427 |
+
"""
|
| 428 |
+
self._phase = PHASE_TRACING
|
| 429 |
+
self._cascade = get_cascade_scenario(seed=seed)
|
| 430 |
+
max_steps = 40
|
| 431 |
+
self._cascade_max_steps = max_steps
|
| 432 |
+
|
| 433 |
+
self._state = ValidatorState(
|
| 434 |
+
episode_id=episode_id or str(uuid4()),
|
| 435 |
+
step_count=0,
|
| 436 |
+
task_name=task_name,
|
| 437 |
+
phase=PHASE_TRACING,
|
| 438 |
+
total_consumers=len(self._cascade.consumers),
|
| 439 |
+
consumers_correctly_traced=0,
|
| 440 |
+
consumers_missed=len(self._cascade.ground_truth_affected),
|
| 441 |
+
fix_attempts=0,
|
| 442 |
+
fix_validated=False,
|
| 443 |
+
score=0.01,
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
logger.info(json.dumps({
|
| 447 |
+
"event": "episode_start",
|
| 448 |
+
"episode_id": self._state.episode_id,
|
| 449 |
+
"task": task_name,
|
| 450 |
+
"phase": self._phase,
|
| 451 |
+
"scenario": self._cascade.scenario_id,
|
| 452 |
+
"max_steps": max_steps,
|
| 453 |
+
"ts": _now(),
|
| 454 |
+
}))
|
| 455 |
+
|
| 456 |
+
return ValidatorObservation(
|
| 457 |
+
done=False,
|
| 458 |
+
reward=0.0,
|
| 459 |
+
task_name=task_name,
|
| 460 |
+
task_description=(
|
| 461 |
+
"Multi-phase cascade: first trace_impact to identify "
|
| 462 |
+
"affected consumers, then propose_fix with a backward-"
|
| 463 |
+
"compatible migration. Episode ends when the fix passes "
|
| 464 |
+
"all consumers or the step budget runs out."
|
| 465 |
+
),
|
| 466 |
+
phase=PHASE_TRACING,
|
| 467 |
+
service_graph=public_observation(self._cascade),
|
| 468 |
+
detected_violation=self._cascade.violation,
|
| 469 |
+
consumer_specs=consumer_specs_for_fix(self._cascade),
|
| 470 |
+
total_consumers=len(self._cascade.consumers),
|
| 471 |
+
feedback=(
|
| 472 |
+
"Cascade episode started in Phase 2. Submit trace_impact "
|
| 473 |
+
"first, then move on to propose_fix."
|
| 474 |
+
),
|
| 475 |
+
max_steps=max_steps,
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
# ── step ──────────────────────────────────────────────────────────
|
| 479 |
|
| 480 |
def step(
|
|
|
|
| 483 |
timeout_s: Optional[float] = None,
|
| 484 |
**kwargs: Any,
|
| 485 |
) -> ValidatorObservation:
|
| 486 |
+
"""Dispatch one agent action to the matching phase handler."""
|
| 487 |
+
if self._scenario is None and self._cascade is None:
|
| 488 |
raise RuntimeError("Call reset() before step().")
|
| 489 |
|
| 490 |
+
# Phase 2 — single-step trace
|
| 491 |
+
if (
|
| 492 |
+
action.action_type == ACTION_TRACE_IMPACT
|
| 493 |
+
and self._cascade is not None
|
| 494 |
+
):
|
| 495 |
+
return self._step_trace_impact(action)
|
| 496 |
+
|
| 497 |
+
# Phase 3 — fix proposal / validation
|
| 498 |
+
if (
|
| 499 |
+
action.action_type in (ACTION_PROPOSE_FIX, ACTION_VALIDATE_FIX)
|
| 500 |
+
and self._cascade is not None
|
| 501 |
+
):
|
| 502 |
+
return self._step_fix(action)
|
| 503 |
+
|
| 504 |
+
# Default — Phase 1 detection (handles report_violation, DONE, HINT)
|
| 505 |
+
if self._scenario is None:
|
| 506 |
+
return self._build_observation_phase2(
|
| 507 |
+
reward=-0.5,
|
| 508 |
+
done=False,
|
| 509 |
+
feedback=(
|
| 510 |
+
f"Action type '{action.action_type}' is not valid in "
|
| 511 |
+
f"phase '{self._phase}'."
|
| 512 |
+
),
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
self._state.step_count += 1
|
| 516 |
signal = action.field_path.strip().upper()
|
| 517 |
|
|
|
|
| 704 |
|
| 705 |
# ── helpers ────────────────────────────────────────────────────────
|
| 706 |
|
| 707 |
+
# ── Phase 2 step — trace_impact ──────────────────────────────────
|
| 708 |
+
|
| 709 |
+
def _step_trace_impact(
|
| 710 |
+
self, action: ValidatorAction
|
| 711 |
+
) -> ValidatorObservation:
|
| 712 |
+
"""Grade a single trace_impact action against ground truth."""
|
| 713 |
+
assert self._cascade is not None
|
| 714 |
+
|
| 715 |
+
self._state.step_count += 1
|
| 716 |
+
|
| 717 |
+
result = trace_impact(self._cascade, action.affected_services)
|
| 718 |
+
rubric = phase2_trace_rubric(result)
|
| 719 |
+
reward = rubric.total
|
| 720 |
+
|
| 721 |
+
self._consumers_traced.update(result.correct_hits)
|
| 722 |
+
self._state.consumers_correctly_traced = len(result.correct_hits)
|
| 723 |
+
self._state.consumers_missed = len(result.missed)
|
| 724 |
+
self._state.consumers_false_flagged = len(result.false_flags)
|
| 725 |
+
|
| 726 |
+
# In a pure Phase-2 task, one trace ends the episode.
|
| 727 |
+
# In cascade, a fully-correct trace transitions to Phase 3.
|
| 728 |
+
is_cascade = self._state.task_name in CASCADE_TASKS
|
| 729 |
+
all_correct = not result.missed and not result.false_flags
|
| 730 |
+
steps_exhausted = self._state.step_count >= self._cascade_max_steps
|
| 731 |
+
|
| 732 |
+
if is_cascade and all_correct and not steps_exhausted:
|
| 733 |
+
self._phase = PHASE_FIX
|
| 734 |
+
self._state.phase = PHASE_FIX
|
| 735 |
+
done = False
|
| 736 |
+
feedback = (
|
| 737 |
+
"All consumers correctly traced. Phase 3 unlocked — submit "
|
| 738 |
+
"propose_fix with a backward-compatible spec_patch."
|
| 739 |
+
)
|
| 740 |
+
else:
|
| 741 |
+
done = True
|
| 742 |
+
self._state.score = phase2_episode_score(result)
|
| 743 |
+
feedback = (
|
| 744 |
+
f"Phase 2 result — precision {result.precision:.2f}, "
|
| 745 |
+
f"recall {result.recall:.2f}, f1 {result.f1:.2f}. "
|
| 746 |
+
f"correct={result.correct_hits} | missed={result.missed} | "
|
| 747 |
+
f"false-flagged={result.false_flags}"
|
| 748 |
+
)
|
| 749 |
+
|
| 750 |
+
if steps_exhausted and not done:
|
| 751 |
+
done = True
|
| 752 |
+
self._state.score = phase2_episode_score(result)
|
| 753 |
+
feedback += " Step budget exhausted."
|
| 754 |
+
|
| 755 |
+
return self._build_observation_phase2(
|
| 756 |
+
reward=round(reward, 4),
|
| 757 |
+
done=done,
|
| 758 |
+
feedback=feedback,
|
| 759 |
+
rubric_components=rubric.to_dict(),
|
| 760 |
+
)
|
| 761 |
+
|
| 762 |
+
# ── Phase 3 step — propose_fix / validate_fix ────────────────────
|
| 763 |
+
|
| 764 |
+
def _step_fix(self, action: ValidatorAction) -> ValidatorObservation:
|
| 765 |
+
"""Grade a fix proposal against every consumer in the scenario."""
|
| 766 |
+
assert self._cascade is not None
|
| 767 |
+
|
| 768 |
+
self._state.step_count += 1
|
| 769 |
+
self._state.fix_attempts += 1
|
| 770 |
+
|
| 771 |
+
fix_result = validate_fix(
|
| 772 |
+
self._cascade, action.fix_strategy, action.spec_patch
|
| 773 |
+
)
|
| 774 |
+
rubric = phase3_fix_rubric(fix_result)
|
| 775 |
+
reward = rubric.total
|
| 776 |
+
|
| 777 |
+
self._state.fix_validated = fix_result.all_consumers_pass
|
| 778 |
+
self._state.fix_breaks_consumers = len(fix_result.consumers_failing)
|
| 779 |
+
self._last_fix_results = {
|
| 780 |
+
"strategy": fix_result.strategy,
|
| 781 |
+
"consumers_passing": fix_result.consumers_passing,
|
| 782 |
+
"consumers_failing": fix_result.consumers_failing,
|
| 783 |
+
"failure_reasons": fix_result.failure_reasons,
|
| 784 |
+
"notes": fix_result.notes,
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
steps_exhausted = self._state.step_count >= self._cascade_max_steps
|
| 788 |
+
done = fix_result.all_consumers_pass or steps_exhausted
|
| 789 |
+
|
| 790 |
+
if done:
|
| 791 |
+
self._state.score = phase3_episode_score(fix_result)
|
| 792 |
+
|
| 793 |
+
if fix_result.all_consumers_pass:
|
| 794 |
+
feedback = (
|
| 795 |
+
f"Fix accepted — strategy '{fix_result.strategy}' "
|
| 796 |
+
f"validates against all "
|
| 797 |
+
f"{len(fix_result.consumers_passing)} consumer(s). "
|
| 798 |
+
f"Episode complete."
|
| 799 |
+
)
|
| 800 |
+
elif not fix_result.is_well_formed:
|
| 801 |
+
feedback = (
|
| 802 |
+
f"Malformed fix proposal: "
|
| 803 |
+
f"{'; '.join(fix_result.notes) or 'see field requirements'}."
|
| 804 |
+
)
|
| 805 |
+
else:
|
| 806 |
+
feedback = (
|
| 807 |
+
f"Fix breaks {len(fix_result.consumers_failing)} consumer(s): "
|
| 808 |
+
f"{fix_result.consumers_failing}. "
|
| 809 |
+
f"Refine the spec_patch and try again."
|
| 810 |
+
)
|
| 811 |
+
if steps_exhausted:
|
| 812 |
+
feedback += " Step budget exhausted."
|
| 813 |
+
|
| 814 |
+
return self._build_observation_phase3(
|
| 815 |
+
reward=round(reward, 4),
|
| 816 |
+
done=done,
|
| 817 |
+
feedback=feedback,
|
| 818 |
+
fix_validation_results=self._last_fix_results,
|
| 819 |
+
rubric_components=rubric.to_dict(),
|
| 820 |
+
)
|
| 821 |
+
|
| 822 |
+
# ── Phase 2 observation builder ──────────────────────────────────
|
| 823 |
+
|
| 824 |
+
def _build_observation_phase2(
|
| 825 |
+
self,
|
| 826 |
+
*,
|
| 827 |
+
reward: float,
|
| 828 |
+
done: bool,
|
| 829 |
+
feedback: str,
|
| 830 |
+
rubric_components: Optional[Dict[str, Any]] = None,
|
| 831 |
+
) -> ValidatorObservation:
|
| 832 |
+
assert self._cascade is not None
|
| 833 |
+
logger.debug(json.dumps({
|
| 834 |
+
"event": "step",
|
| 835 |
+
"episode_id": self._state.episode_id,
|
| 836 |
+
"task": self._state.task_name,
|
| 837 |
+
"phase": self._phase,
|
| 838 |
+
"step": self._state.step_count,
|
| 839 |
+
"reward": reward,
|
| 840 |
+
"done": done,
|
| 841 |
+
"rubric": rubric_components,
|
| 842 |
+
"ts": _now(),
|
| 843 |
+
}))
|
| 844 |
+
if done:
|
| 845 |
+
logger.info(json.dumps({
|
| 846 |
+
"event": "episode_end",
|
| 847 |
+
"episode_id": self._state.episode_id,
|
| 848 |
+
"task": self._state.task_name,
|
| 849 |
+
"phase": self._phase,
|
| 850 |
+
"score": round(self._state.score, 4),
|
| 851 |
+
"steps": self._state.step_count,
|
| 852 |
+
"consumers_correctly_traced": self._state.consumers_correctly_traced,
|
| 853 |
+
"consumers_missed": self._state.consumers_missed,
|
| 854 |
+
"consumers_false_flagged": self._state.consumers_false_flagged,
|
| 855 |
+
"ts": _now(),
|
| 856 |
+
}))
|
| 857 |
+
return ValidatorObservation(
|
| 858 |
+
done=done,
|
| 859 |
+
reward=reward,
|
| 860 |
+
task_name=self._state.task_name,
|
| 861 |
+
task_description="",
|
| 862 |
+
phase=self._phase,
|
| 863 |
+
service_graph=public_observation(self._cascade),
|
| 864 |
+
consumers_traced=sorted(self._consumers_traced),
|
| 865 |
+
total_consumers=len(self._cascade.consumers),
|
| 866 |
+
detected_violation=self._cascade.violation,
|
| 867 |
+
consumer_specs=consumer_specs_for_fix(self._cascade),
|
| 868 |
+
feedback=feedback,
|
| 869 |
+
max_steps=self._cascade_max_steps,
|
| 870 |
+
)
|
| 871 |
+
|
| 872 |
+
# ── Phase 3 observation builder ──────────────────────────────────
|
| 873 |
+
|
| 874 |
+
def _build_observation_phase3(
|
| 875 |
+
self,
|
| 876 |
+
*,
|
| 877 |
+
reward: float,
|
| 878 |
+
done: bool,
|
| 879 |
+
feedback: str,
|
| 880 |
+
fix_validation_results: Dict[str, Any],
|
| 881 |
+
rubric_components: Optional[Dict[str, Any]] = None,
|
| 882 |
+
) -> ValidatorObservation:
|
| 883 |
+
assert self._cascade is not None
|
| 884 |
+
logger.debug(json.dumps({
|
| 885 |
+
"event": "step",
|
| 886 |
+
"episode_id": self._state.episode_id,
|
| 887 |
+
"task": self._state.task_name,
|
| 888 |
+
"phase": self._phase,
|
| 889 |
+
"step": self._state.step_count,
|
| 890 |
+
"reward": reward,
|
| 891 |
+
"done": done,
|
| 892 |
+
"fix_validation": fix_validation_results,
|
| 893 |
+
"rubric": rubric_components,
|
| 894 |
+
"ts": _now(),
|
| 895 |
+
}))
|
| 896 |
+
if done:
|
| 897 |
+
logger.info(json.dumps({
|
| 898 |
+
"event": "episode_end",
|
| 899 |
+
"episode_id": self._state.episode_id,
|
| 900 |
+
"task": self._state.task_name,
|
| 901 |
+
"phase": self._phase,
|
| 902 |
+
"score": round(self._state.score, 4),
|
| 903 |
+
"steps": self._state.step_count,
|
| 904 |
+
"fix_validated": self._state.fix_validated,
|
| 905 |
+
"fix_attempts": self._state.fix_attempts,
|
| 906 |
+
"ts": _now(),
|
| 907 |
+
}))
|
| 908 |
+
return ValidatorObservation(
|
| 909 |
+
done=done,
|
| 910 |
+
reward=reward,
|
| 911 |
+
task_name=self._state.task_name,
|
| 912 |
+
task_description="",
|
| 913 |
+
phase=self._phase,
|
| 914 |
+
service_graph=public_observation(self._cascade),
|
| 915 |
+
consumers_traced=sorted(self._consumers_traced),
|
| 916 |
+
total_consumers=len(self._cascade.consumers),
|
| 917 |
+
detected_violation=self._cascade.violation,
|
| 918 |
+
consumer_specs=consumer_specs_for_fix(self._cascade),
|
| 919 |
+
fix_validation_results=fix_validation_results,
|
| 920 |
+
feedback=feedback,
|
| 921 |
+
max_steps=self._cascade_max_steps,
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
def _build_observation(
|
| 925 |
self,
|
| 926 |
*,
|
|
|
|
| 931 |
"""Construct an observation from current state."""
|
| 932 |
assert self._scenario is not None
|
| 933 |
remaining = self._state.total_violations - self._state.correct_reports
|
| 934 |
+
|
| 935 |
+
logger.debug(json.dumps({
|
| 936 |
+
"event": "step",
|
| 937 |
+
"episode_id": self._state.episode_id,
|
| 938 |
+
"task": self._state.task_name,
|
| 939 |
+
"step": self._state.step_count,
|
| 940 |
+
"reward": round(reward, 4),
|
| 941 |
+
"correct_so_far": self._state.correct_reports,
|
| 942 |
+
"total_violations": self._state.total_violations,
|
| 943 |
+
"done": done,
|
| 944 |
+
"ts": _now(),
|
| 945 |
+
}))
|
| 946 |
+
|
| 947 |
+
if done:
|
| 948 |
+
logger.info(json.dumps({
|
| 949 |
+
"event": "episode_end",
|
| 950 |
+
"episode_id": self._state.episode_id,
|
| 951 |
+
"task": self._state.task_name,
|
| 952 |
+
"score": round(self._state.score, 4),
|
| 953 |
+
"steps": self._state.step_count,
|
| 954 |
+
"correct": self._state.correct_reports,
|
| 955 |
+
"total": self._state.total_violations,
|
| 956 |
+
"false_positives": self._state.false_positives,
|
| 957 |
+
"duplicates": self._state.duplicate_reports,
|
| 958 |
+
"ts": _now(),
|
| 959 |
+
}))
|
| 960 |
+
|
| 961 |
return ValidatorObservation(
|
| 962 |
done=done,
|
| 963 |
reward=reward,
|
| 964 |
task_name=self._scenario.task_name,
|
| 965 |
task_description=self._scenario.task_description,
|
| 966 |
+
phase=PHASE_DETECTION,
|
| 967 |
api_spec=self._scenario.api_spec,
|
| 968 |
payload=self._scenario.payload,
|
| 969 |
violations_found=list(self._reported_violations),
|
server/fix_validator.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase 3 — backward-compatibility fix validation.
|
| 3 |
+
|
| 4 |
+
Five strategies are accepted, each grading rule is independent so the
|
| 5 |
+
reward layer can compose a rubric:
|
| 6 |
+
|
| 7 |
+
field_alias — keep the old field name as an alias to the new one
|
| 8 |
+
version_bump — expose the change behind a new API version
|
| 9 |
+
deprecation_window — keep the old field, mark it deprecated, document removal
|
| 10 |
+
dual_write — emit both the old and new field for a transition period
|
| 11 |
+
consumer_patch — coordinate consumer updates (only valid when the
|
| 12 |
+
producer cannot retain backward compat, e.g. enum
|
| 13 |
+
narrowing where the old values are illegal upstream)
|
| 14 |
+
|
| 15 |
+
A fix is graded against every consumer in the scenario's service graph.
|
| 16 |
+
The result includes per-consumer pass/fail so the reward layer can
|
| 17 |
+
penalise partial fixes (breaks ≥1 consumer) without false-rewarding
|
| 18 |
+
fixes that happen to satisfy the easy consumer but break the hard one.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from typing import Any, Dict, List
|
| 23 |
+
|
| 24 |
+
from .service_graph import CascadeScenario, ConsumerDeclaration
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
VALID_STRATEGIES = {
|
| 28 |
+
"field_alias",
|
| 29 |
+
"version_bump",
|
| 30 |
+
"deprecation_window",
|
| 31 |
+
"dual_write",
|
| 32 |
+
"consumer_patch",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class FixValidationResult:
|
| 38 |
+
"""Outcome of validating one fix proposal against all consumers."""
|
| 39 |
+
|
| 40 |
+
strategy: str
|
| 41 |
+
is_well_formed: bool
|
| 42 |
+
is_strategy_acceptable: bool
|
| 43 |
+
consumers_passing: List[str] = field(default_factory=list)
|
| 44 |
+
consumers_failing: List[str] = field(default_factory=list)
|
| 45 |
+
failure_reasons: Dict[str, str] = field(default_factory=dict)
|
| 46 |
+
notes: List[str] = field(default_factory=list)
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def all_consumers_pass(self) -> bool:
|
| 50 |
+
return (
|
| 51 |
+
self.is_well_formed
|
| 52 |
+
and self.is_strategy_acceptable
|
| 53 |
+
and len(self.consumers_failing) == 0
|
| 54 |
+
and len(self.consumers_passing) > 0
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ── Strategy-specific consumer checks ────────────────────────────────────
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _check_field_alias(
|
| 62 |
+
scenario: CascadeScenario,
|
| 63 |
+
spec_patch: Dict[str, Any],
|
| 64 |
+
consumer: ConsumerDeclaration,
|
| 65 |
+
) -> tuple[bool, str]:
|
| 66 |
+
"""Pass if the patch reintroduces every consumed field as an alias."""
|
| 67 |
+
aliases = spec_patch.get("aliases") or spec_patch.get("field_aliases") or {}
|
| 68 |
+
if not isinstance(aliases, dict) or not aliases:
|
| 69 |
+
return False, "patch missing 'aliases' map"
|
| 70 |
+
|
| 71 |
+
if scenario.scenario_id == "user_email_rename":
|
| 72 |
+
# Patch must alias the old name back to the new name
|
| 73 |
+
if "email" not in aliases:
|
| 74 |
+
return False, "no alias for 'email'"
|
| 75 |
+
target = aliases["email"]
|
| 76 |
+
if target != "email_address":
|
| 77 |
+
return False, f"alias points to '{target}', expected 'email_address'"
|
| 78 |
+
# Consumer passes if it still consumes 'email' (alias covers it)
|
| 79 |
+
if "email" in consumer.fields_consumed:
|
| 80 |
+
return True, ""
|
| 81 |
+
return True, "consumer not affected"
|
| 82 |
+
|
| 83 |
+
if scenario.scenario_id == "orders_status_narrowed":
|
| 84 |
+
# Aliasing doesn't help an enum narrowing — reject for affected consumers
|
| 85 |
+
affected = consumer.name in scenario.ground_truth_affected
|
| 86 |
+
if affected:
|
| 87 |
+
return False, "field_alias cannot restore removed enum values"
|
| 88 |
+
return True, "consumer not affected"
|
| 89 |
+
|
| 90 |
+
return False, "unknown scenario for field_alias"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _check_version_bump(
|
| 94 |
+
scenario: CascadeScenario,
|
| 95 |
+
spec_patch: Dict[str, Any],
|
| 96 |
+
consumer: ConsumerDeclaration,
|
| 97 |
+
) -> tuple[bool, str]:
|
| 98 |
+
"""Pass if the patch declares both v1 (legacy) and v2 endpoints."""
|
| 99 |
+
versions = spec_patch.get("versions") or []
|
| 100 |
+
if not isinstance(versions, list) or len(versions) < 2:
|
| 101 |
+
return False, "patch missing two-version declaration"
|
| 102 |
+
has_legacy = any("v1" in str(v).lower() or "1.0" in str(v) for v in versions)
|
| 103 |
+
has_new = any("v2" in str(v).lower() or "2.0" in str(v) for v in versions)
|
| 104 |
+
if not (has_legacy and has_new):
|
| 105 |
+
return False, "patch must keep v1 alongside v2"
|
| 106 |
+
return True, ""
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _check_deprecation_window(
|
| 110 |
+
scenario: CascadeScenario,
|
| 111 |
+
spec_patch: Dict[str, Any],
|
| 112 |
+
consumer: ConsumerDeclaration,
|
| 113 |
+
) -> tuple[bool, str]:
|
| 114 |
+
"""Pass if the patch keeps the old field/enum and marks it deprecated."""
|
| 115 |
+
if scenario.scenario_id == "user_email_rename":
|
| 116 |
+
deprecated = spec_patch.get("deprecated_fields") or []
|
| 117 |
+
if "email" not in deprecated:
|
| 118 |
+
return False, "must list 'email' under deprecated_fields"
|
| 119 |
+
return True, ""
|
| 120 |
+
|
| 121 |
+
if scenario.scenario_id == "orders_status_narrowed":
|
| 122 |
+
deprecated_values = spec_patch.get("deprecated_enum_values") or []
|
| 123 |
+
for value in ("cancelled", "refunded"):
|
| 124 |
+
if value not in deprecated_values:
|
| 125 |
+
return False, f"must keep '{value}' as deprecated enum"
|
| 126 |
+
return True, ""
|
| 127 |
+
|
| 128 |
+
return False, "unknown scenario for deprecation_window"
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _check_dual_write(
|
| 132 |
+
scenario: CascadeScenario,
|
| 133 |
+
spec_patch: Dict[str, Any],
|
| 134 |
+
consumer: ConsumerDeclaration,
|
| 135 |
+
) -> tuple[bool, str]:
|
| 136 |
+
"""Pass if the patch emits both old and new field names simultaneously."""
|
| 137 |
+
fields = spec_patch.get("emit_fields") or []
|
| 138 |
+
if scenario.scenario_id == "user_email_rename":
|
| 139 |
+
if "email" not in fields or "email_address" not in fields:
|
| 140 |
+
return False, "must emit both 'email' and 'email_address'"
|
| 141 |
+
return True, ""
|
| 142 |
+
return False, "dual_write not supported for this scenario"
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _check_consumer_patch(
|
| 146 |
+
scenario: CascadeScenario,
|
| 147 |
+
spec_patch: Dict[str, Any],
|
| 148 |
+
consumer: ConsumerDeclaration,
|
| 149 |
+
) -> tuple[bool, str]:
|
| 150 |
+
"""Pass if the patch lists every truly-affected consumer to migrate."""
|
| 151 |
+
migrate = spec_patch.get("consumers_to_migrate") or []
|
| 152 |
+
if not isinstance(migrate, list):
|
| 153 |
+
return False, "consumers_to_migrate must be a list"
|
| 154 |
+
if consumer.name in scenario.ground_truth_affected:
|
| 155 |
+
if consumer.name not in migrate:
|
| 156 |
+
return False, "affected consumer missing from migration list"
|
| 157 |
+
return True, ""
|
| 158 |
+
return True, ""
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
_STRATEGY_CHECKERS = {
|
| 162 |
+
"field_alias": _check_field_alias,
|
| 163 |
+
"version_bump": _check_version_bump,
|
| 164 |
+
"deprecation_window": _check_deprecation_window,
|
| 165 |
+
"dual_write": _check_dual_write,
|
| 166 |
+
"consumer_patch": _check_consumer_patch,
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ── Public entry point ────────────────────────────────────────────────────
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def validate_fix(
|
| 174 |
+
scenario: CascadeScenario,
|
| 175 |
+
strategy: str,
|
| 176 |
+
spec_patch: Dict[str, Any],
|
| 177 |
+
) -> FixValidationResult:
|
| 178 |
+
"""Validate a fix proposal against all consumers in the scenario."""
|
| 179 |
+
notes: List[str] = []
|
| 180 |
+
|
| 181 |
+
if not isinstance(spec_patch, dict):
|
| 182 |
+
return FixValidationResult(
|
| 183 |
+
strategy=strategy,
|
| 184 |
+
is_well_formed=False,
|
| 185 |
+
is_strategy_acceptable=False,
|
| 186 |
+
notes=["spec_patch must be a JSON object"],
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
if not strategy or strategy not in VALID_STRATEGIES:
|
| 190 |
+
return FixValidationResult(
|
| 191 |
+
strategy=strategy,
|
| 192 |
+
is_well_formed=False,
|
| 193 |
+
is_strategy_acceptable=False,
|
| 194 |
+
notes=[
|
| 195 |
+
f"strategy '{strategy}' not in {sorted(VALID_STRATEGIES)}"
|
| 196 |
+
],
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
is_acceptable = strategy in scenario.acceptable_fix_strategies
|
| 200 |
+
if not is_acceptable:
|
| 201 |
+
notes.append(
|
| 202 |
+
f"'{strategy}' not in acceptable strategies "
|
| 203 |
+
f"{scenario.acceptable_fix_strategies} for this scenario"
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
checker = _STRATEGY_CHECKERS[strategy]
|
| 207 |
+
passing: List[str] = []
|
| 208 |
+
failing: List[str] = []
|
| 209 |
+
reasons: Dict[str, str] = {}
|
| 210 |
+
|
| 211 |
+
for consumer in scenario.consumers:
|
| 212 |
+
ok, reason = checker(scenario, spec_patch, consumer)
|
| 213 |
+
if ok:
|
| 214 |
+
passing.append(consumer.name)
|
| 215 |
+
else:
|
| 216 |
+
failing.append(consumer.name)
|
| 217 |
+
reasons[consumer.name] = reason
|
| 218 |
+
|
| 219 |
+
return FixValidationResult(
|
| 220 |
+
strategy=strategy,
|
| 221 |
+
is_well_formed=True,
|
| 222 |
+
is_strategy_acceptable=is_acceptable,
|
| 223 |
+
consumers_passing=passing,
|
| 224 |
+
consumers_failing=failing,
|
| 225 |
+
failure_reasons=reasons,
|
| 226 |
+
notes=notes,
|
| 227 |
+
)
|
server/impact_tracer.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase 2 — ground-truth impact tracing.
|
| 3 |
+
|
| 4 |
+
Given an agent's predicted list of affected consumers, compare against
|
| 5 |
+
the cascade scenario's ground truth and emit a precision/recall-style
|
| 6 |
+
``ImpactTraceResult`` so the reward layer can score each consumer
|
| 7 |
+
decision independently (composable rubric).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import List
|
| 12 |
+
|
| 13 |
+
from .service_graph import CascadeScenario
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class ImpactTraceResult:
|
| 18 |
+
"""Per-consumer outcome of one ``trace_impact`` action."""
|
| 19 |
+
|
| 20 |
+
correct_hits: List[str] = field(default_factory=list)
|
| 21 |
+
missed: List[str] = field(default_factory=list)
|
| 22 |
+
false_flags: List[str] = field(default_factory=list)
|
| 23 |
+
unknown_services: List[str] = field(default_factory=list)
|
| 24 |
+
total_consumers: int = 0
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def precision(self) -> float:
|
| 28 |
+
flagged = len(self.correct_hits) + len(self.false_flags)
|
| 29 |
+
if flagged == 0:
|
| 30 |
+
return 0.0
|
| 31 |
+
return len(self.correct_hits) / flagged
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def recall(self) -> float:
|
| 35 |
+
truly_affected = len(self.correct_hits) + len(self.missed)
|
| 36 |
+
if truly_affected == 0:
|
| 37 |
+
return 1.0 # nothing to find
|
| 38 |
+
return len(self.correct_hits) / truly_affected
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def f1(self) -> float:
|
| 42 |
+
p, r = self.precision, self.recall
|
| 43 |
+
if p + r == 0:
|
| 44 |
+
return 0.0
|
| 45 |
+
return 2 * p * r / (p + r)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _normalise(name: str) -> str:
|
| 49 |
+
return name.strip().lower()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def trace_impact(
|
| 53 |
+
scenario: CascadeScenario,
|
| 54 |
+
predicted_affected: List[str],
|
| 55 |
+
) -> ImpactTraceResult:
|
| 56 |
+
"""Compare agent's predicted consumer list against ground truth.
|
| 57 |
+
|
| 58 |
+
``predicted_affected`` is matched case-insensitively. Names that match
|
| 59 |
+
no consumer in the scenario are reported in ``unknown_services`` and
|
| 60 |
+
treated as false flags for reward purposes.
|
| 61 |
+
"""
|
| 62 |
+
truth_lookup = {_normalise(n): n for n in scenario.ground_truth_affected}
|
| 63 |
+
known_consumers = {_normalise(c.name): c.name for c in scenario.consumers}
|
| 64 |
+
|
| 65 |
+
seen: set = set()
|
| 66 |
+
correct_hits: List[str] = []
|
| 67 |
+
false_flags: List[str] = []
|
| 68 |
+
unknown_services: List[str] = []
|
| 69 |
+
|
| 70 |
+
for raw in predicted_affected:
|
| 71 |
+
key = _normalise(raw)
|
| 72 |
+
if key in seen:
|
| 73 |
+
continue
|
| 74 |
+
seen.add(key)
|
| 75 |
+
|
| 76 |
+
if key in truth_lookup:
|
| 77 |
+
correct_hits.append(truth_lookup[key])
|
| 78 |
+
elif key in known_consumers:
|
| 79 |
+
false_flags.append(known_consumers[key])
|
| 80 |
+
else:
|
| 81 |
+
unknown_services.append(raw)
|
| 82 |
+
|
| 83 |
+
missed = [
|
| 84 |
+
name
|
| 85 |
+
for name in scenario.ground_truth_affected
|
| 86 |
+
if _normalise(name) not in {_normalise(c) for c in correct_hits}
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
return ImpactTraceResult(
|
| 90 |
+
correct_hits=correct_hits,
|
| 91 |
+
missed=missed,
|
| 92 |
+
false_flags=false_flags,
|
| 93 |
+
unknown_services=unknown_services,
|
| 94 |
+
total_consumers=len(scenario.consumers),
|
| 95 |
+
)
|
server/logging_setup.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structured JSON logging for API Contract Validator.
|
| 2 |
+
|
| 3 |
+
Outputs one JSON object per log record to stdout (visible in docker logs)
|
| 4 |
+
and, when writable, to logs/episodes.jsonl for persistent episode history.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class _JsonFormatter(logging.Formatter):
|
| 16 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 17 |
+
payload: dict = {
|
| 18 |
+
"ts": datetime.now(timezone.utc).isoformat(),
|
| 19 |
+
"level": record.levelname,
|
| 20 |
+
"logger": record.name,
|
| 21 |
+
"msg": record.getMessage(),
|
| 22 |
+
}
|
| 23 |
+
if record.exc_info:
|
| 24 |
+
payload["exc"] = self.formatException(record.exc_info)
|
| 25 |
+
return json.dumps(payload)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def configure_logging() -> None:
|
| 29 |
+
"""Configure root logger: JSON to stdout + logs/episodes.jsonl."""
|
| 30 |
+
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
| 31 |
+
|
| 32 |
+
root = logging.getLogger()
|
| 33 |
+
if root.handlers:
|
| 34 |
+
return # already configured (e.g. during testing)
|
| 35 |
+
|
| 36 |
+
root.setLevel(log_level)
|
| 37 |
+
|
| 38 |
+
sh = logging.StreamHandler(sys.stdout)
|
| 39 |
+
sh.setFormatter(_JsonFormatter())
|
| 40 |
+
root.addHandler(sh)
|
| 41 |
+
|
| 42 |
+
log_dir = Path(os.getenv("LOG_DIR", "logs"))
|
| 43 |
+
try:
|
| 44 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
fh = logging.FileHandler(log_dir / "episodes.jsonl", encoding="utf-8")
|
| 46 |
+
fh.setFormatter(_JsonFormatter())
|
| 47 |
+
root.addHandler(fh)
|
| 48 |
+
except OSError:
|
| 49 |
+
pass # read-only filesystem (HF Spaces free tier) — stdout only
|
server/rewards.py
CHANGED
|
@@ -1,28 +1,113 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
|
| 20 |
-
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
@dataclass
|
| 24 |
class RewardBreakdown:
|
| 25 |
-
"""Detailed breakdown of a single
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
reward: float
|
| 28 |
is_correct: bool
|
|
@@ -34,14 +119,30 @@ class RewardBreakdown:
|
|
| 34 |
explanation: str
|
| 35 |
|
| 36 |
|
| 37 |
-
#
|
| 38 |
-
|
| 39 |
CORRECT_VIOLATION_REWARD = 1.0
|
| 40 |
-
PATH_MATCH_REWARD = 0.3
|
| 41 |
-
HINT_PENALTY = -0.5
|
| 42 |
DUPLICATE_PENALTY = -0.1
|
| 43 |
FALSE_POSITIVE_PENALTY = -0.3
|
| 44 |
-
DONE_BONUS_MULTIPLIER = 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
def compute_step_reward(
|
|
@@ -54,30 +155,8 @@ def compute_step_reward(
|
|
| 54 |
correct_so_far: int,
|
| 55 |
total_violations: int,
|
| 56 |
) -> RewardBreakdown:
|
| 57 |
-
"""Compute
|
| 58 |
-
|
| 59 |
-
Parameters
|
| 60 |
-
----------
|
| 61 |
-
is_correct:
|
| 62 |
-
Whether the report fully matches a ground-truth violation (path + type).
|
| 63 |
-
is_path_match:
|
| 64 |
-
Whether the field_path matches a violation but violation_type is wrong.
|
| 65 |
-
is_duplicate:
|
| 66 |
-
Whether the agent already reported this violation.
|
| 67 |
-
is_done_signal:
|
| 68 |
-
Whether the agent submitted ``field_path='DONE'``.
|
| 69 |
-
is_hint:
|
| 70 |
-
Whether the agent submitted ``field_path='HINT'``.
|
| 71 |
-
correct_so_far:
|
| 72 |
-
Number of unique correct violations found before this step.
|
| 73 |
-
total_violations:
|
| 74 |
-
Total planted violations in the current scenario.
|
| 75 |
-
|
| 76 |
-
Returns
|
| 77 |
-
-------
|
| 78 |
-
RewardBreakdown
|
| 79 |
-
Contains the scalar reward and a human-readable explanation.
|
| 80 |
-
"""
|
| 81 |
if is_hint:
|
| 82 |
return RewardBreakdown(
|
| 83 |
reward=HINT_PENALTY,
|
|
@@ -93,8 +172,6 @@ def compute_step_reward(
|
|
| 93 |
if is_done_signal:
|
| 94 |
completeness = correct_so_far / max(total_violations, 1)
|
| 95 |
bonus = DONE_BONUS_MULTIPLIER * completeness
|
| 96 |
-
# Clamp to strictly (0, 1) — evaluator requires score never equals 0.0 or 1.0
|
| 97 |
-
# Use 0.01/0.99 so value is still non-zero/non-one after :.2f formatting
|
| 98 |
bonus = round(max(0.01, min(0.99, bonus)), 4)
|
| 99 |
return RewardBreakdown(
|
| 100 |
reward=bonus,
|
|
@@ -105,9 +182,8 @@ def compute_step_reward(
|
|
| 105 |
is_done_signal=True,
|
| 106 |
is_hint=False,
|
| 107 |
explanation=(
|
| 108 |
-
f"Agent signalled DONE. "
|
| 109 |
-
f"
|
| 110 |
-
f"→ bonus {bonus:.2f}"
|
| 111 |
),
|
| 112 |
)
|
| 113 |
|
|
@@ -145,12 +221,12 @@ def compute_step_reward(
|
|
| 145 |
is_done_signal=False,
|
| 146 |
is_hint=False,
|
| 147 |
explanation=(
|
| 148 |
-
"Correct field location! The field_path matches a
|
| 149 |
-
"but the violation_type is wrong. Try again
|
|
|
|
| 150 |
),
|
| 151 |
)
|
| 152 |
|
| 153 |
-
# False positive
|
| 154 |
return RewardBreakdown(
|
| 155 |
reward=FALSE_POSITIVE_PENALTY,
|
| 156 |
is_correct=False,
|
|
@@ -164,13 +240,135 @@ def compute_step_reward(
|
|
| 164 |
|
| 165 |
|
| 166 |
def compute_episode_score(correct_count: int, total_violations: int) -> float:
|
| 167 |
-
"""
|
| 168 |
-
|
| 169 |
-
Returns a float strictly in ``(0.0, 1.0)`` — endpoints excluded — as
|
| 170 |
-
required by the OpenEnv evaluation pipeline.
|
| 171 |
-
"""
|
| 172 |
if total_violations == 0:
|
| 173 |
return 0.5
|
| 174 |
raw = correct_count / total_violations
|
| 175 |
-
# Use 0.01/0.99 so value is still non-zero/non-one after :.2f formatting
|
| 176 |
return round(max(0.01, min(0.99, raw)), 4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Multi-phase reward computation for the API Contract Validator.
|
| 3 |
+
|
| 4 |
+
This module follows the OpenEnv "composable rubric" pattern: each reward
|
| 5 |
+
signal is an independent ``RubricComponent`` and the total step reward is
|
| 6 |
+
the sum of its components. Independent components have two key benefits:
|
| 7 |
+
|
| 8 |
+
1. Reduces reward-hacking risk — an agent that maximises one component
|
| 9 |
+
while degrading another shows up immediately in per-component logs.
|
| 10 |
+
2. Gives RL training a richer gradient — partial progress on one
|
| 11 |
+
component still produces signal even when others are zero.
|
| 12 |
+
|
| 13 |
+
Phase reward signals
|
| 14 |
+
--------------------
|
| 15 |
+
|
| 16 |
+
Phase 1 — Detection (legacy ``compute_step_reward`` helper kept for
|
| 17 |
+
back-compat with the existing tests; new code should use the rubric API):
|
| 18 |
+
|
| 19 |
+
- correct violation +1.0
|
| 20 |
+
- proximity match +0.3
|
| 21 |
+
- hint requested -0.5
|
| 22 |
+
- duplicate report -0.1
|
| 23 |
+
- false positive -0.3
|
| 24 |
+
- DONE bonus +0.5 * (correct / total)
|
| 25 |
+
|
| 26 |
+
Phase 2 — Impact Tracing:
|
| 27 |
+
|
| 28 |
+
- correct consumer hit +0.8 each
|
| 29 |
+
- missed consumer -0.5 each
|
| 30 |
+
- false-flag consumer -0.4 each
|
| 31 |
+
- unknown service name -0.2 each (sub-rule of false-flag)
|
| 32 |
+
|
| 33 |
+
Phase 3 — Fix & Verify:
|
| 34 |
+
|
| 35 |
+
- fix passes ALL consumers +2.0
|
| 36 |
+
- fix breaks 1+ consumer -1.0
|
| 37 |
+
- malformed spec patch -0.5
|
| 38 |
+
- unacceptable strategy -0.3
|
| 39 |
+
|
| 40 |
+
Cross-cutting:
|
| 41 |
+
|
| 42 |
+
- format compliance -0.2 for malformed action JSON
|
| 43 |
+
- anti-hacking (spam) -1.0 if total reports > 3 * planted violations
|
| 44 |
"""
|
| 45 |
|
| 46 |
+
from dataclasses import dataclass, field
|
| 47 |
+
from typing import List
|
| 48 |
+
|
| 49 |
+
from .fix_validator import FixValidationResult
|
| 50 |
+
from .impact_tracer import ImpactTraceResult
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ── Rubric primitives ────────────────────────────────────────────────────
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@dataclass
|
| 57 |
+
class RubricComponent:
|
| 58 |
+
"""A single named reward signal."""
|
| 59 |
+
|
| 60 |
+
name: str
|
| 61 |
+
score: float
|
| 62 |
+
explanation: str = ""
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class Rubric:
|
| 67 |
+
"""Composition of independent reward signals.
|
| 68 |
+
|
| 69 |
+
The ``total`` property sums every component. Components are kept
|
| 70 |
+
individually so logs and training analysis can show which signal
|
| 71 |
+
moved across episodes (the key requirement for the "Pipeline 10%"
|
| 72 |
+
judging criterion).
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
components: List[RubricComponent] = field(default_factory=list)
|
| 76 |
+
|
| 77 |
+
def add(self, name: str, score: float, explanation: str = "") -> "Rubric":
|
| 78 |
+
self.components.append(
|
| 79 |
+
RubricComponent(name=name, score=score, explanation=explanation)
|
| 80 |
+
)
|
| 81 |
+
return self
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def total(self) -> float:
|
| 85 |
+
return sum(c.score for c in self.components)
|
| 86 |
+
|
| 87 |
+
def to_dict(self) -> dict:
|
| 88 |
+
return {
|
| 89 |
+
"total": round(self.total, 4),
|
| 90 |
+
"components": [
|
| 91 |
+
{
|
| 92 |
+
"name": c.name,
|
| 93 |
+
"score": round(c.score, 4),
|
| 94 |
+
"explanation": c.explanation,
|
| 95 |
+
}
|
| 96 |
+
for c in self.components
|
| 97 |
+
],
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ── Phase 1 — detection (legacy scalar helper kept for backwards compat) ─
|
| 102 |
|
| 103 |
|
| 104 |
@dataclass
|
| 105 |
class RewardBreakdown:
|
| 106 |
+
"""Detailed breakdown of a single Phase 1 step reward.
|
| 107 |
+
|
| 108 |
+
Kept for backwards compatibility with the existing Phase 1 tests and
|
| 109 |
+
inference loop. New phases use ``Rubric`` directly.
|
| 110 |
+
"""
|
| 111 |
|
| 112 |
reward: float
|
| 113 |
is_correct: bool
|
|
|
|
| 119 |
explanation: str
|
| 120 |
|
| 121 |
|
| 122 |
+
# Phase 1 reward constants
|
|
|
|
| 123 |
CORRECT_VIOLATION_REWARD = 1.0
|
| 124 |
+
PATH_MATCH_REWARD = 0.3
|
| 125 |
+
HINT_PENALTY = -0.5
|
| 126 |
DUPLICATE_PENALTY = -0.1
|
| 127 |
FALSE_POSITIVE_PENALTY = -0.3
|
| 128 |
+
DONE_BONUS_MULTIPLIER = 0.5
|
| 129 |
+
|
| 130 |
+
# Phase 2 reward constants
|
| 131 |
+
CORRECT_CONSUMER_REWARD = 0.8
|
| 132 |
+
MISSED_CONSUMER_PENALTY = -0.5
|
| 133 |
+
FALSE_FLAG_PENALTY = -0.4
|
| 134 |
+
UNKNOWN_SERVICE_PENALTY = -0.2
|
| 135 |
+
|
| 136 |
+
# Phase 3 reward constants
|
| 137 |
+
FIX_PASSES_ALL_REWARD = 2.0
|
| 138 |
+
FIX_BREAKS_CONSUMER_PENALTY = -1.0
|
| 139 |
+
MALFORMED_PATCH_PENALTY = -0.5
|
| 140 |
+
UNACCEPTABLE_STRATEGY_PENALTY = -0.3
|
| 141 |
+
|
| 142 |
+
# Cross-cutting
|
| 143 |
+
MALFORMED_ACTION_PENALTY = -0.2
|
| 144 |
+
SPAM_PENALTY = -1.0
|
| 145 |
+
SPAM_THRESHOLD_MULTIPLIER = 3
|
| 146 |
|
| 147 |
|
| 148 |
def compute_step_reward(
|
|
|
|
| 155 |
correct_so_far: int,
|
| 156 |
total_violations: int,
|
| 157 |
) -> RewardBreakdown:
|
| 158 |
+
"""Compute a Phase 1 detection step reward (legacy scalar API)."""
|
| 159 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
if is_hint:
|
| 161 |
return RewardBreakdown(
|
| 162 |
reward=HINT_PENALTY,
|
|
|
|
| 172 |
if is_done_signal:
|
| 173 |
completeness = correct_so_far / max(total_violations, 1)
|
| 174 |
bonus = DONE_BONUS_MULTIPLIER * completeness
|
|
|
|
|
|
|
| 175 |
bonus = round(max(0.01, min(0.99, bonus)), 4)
|
| 176 |
return RewardBreakdown(
|
| 177 |
reward=bonus,
|
|
|
|
| 182 |
is_done_signal=True,
|
| 183 |
is_hint=False,
|
| 184 |
explanation=(
|
| 185 |
+
f"Agent signalled DONE. Completeness "
|
| 186 |
+
f"{correct_so_far}/{total_violations} → bonus {bonus:.2f}"
|
|
|
|
| 187 |
),
|
| 188 |
)
|
| 189 |
|
|
|
|
| 221 |
is_done_signal=False,
|
| 222 |
is_hint=False,
|
| 223 |
explanation=(
|
| 224 |
+
"Correct field location! The field_path matches a "
|
| 225 |
+
"violation, but the violation_type is wrong. Try again "
|
| 226 |
+
"with the right type."
|
| 227 |
),
|
| 228 |
)
|
| 229 |
|
|
|
|
| 230 |
return RewardBreakdown(
|
| 231 |
reward=FALSE_POSITIVE_PENALTY,
|
| 232 |
is_correct=False,
|
|
|
|
| 240 |
|
| 241 |
|
| 242 |
def compute_episode_score(correct_count: int, total_violations: int) -> float:
|
| 243 |
+
"""Final Phase 1 normalised score, strictly in (0, 1)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
if total_violations == 0:
|
| 245 |
return 0.5
|
| 246 |
raw = correct_count / total_violations
|
|
|
|
| 247 |
return round(max(0.01, min(0.99, raw)), 4)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ── Phase 2 — impact tracing (Rubric API) ────────────────────────────────
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def phase2_trace_rubric(result: ImpactTraceResult) -> Rubric:
|
| 254 |
+
"""Build a per-consumer Rubric from a Phase 2 impact-trace result."""
|
| 255 |
+
rubric = Rubric()
|
| 256 |
+
|
| 257 |
+
for hit in result.correct_hits:
|
| 258 |
+
rubric.add(
|
| 259 |
+
name=f"consumer_correct:{hit}",
|
| 260 |
+
score=CORRECT_CONSUMER_REWARD,
|
| 261 |
+
explanation=f"Correctly identified affected consumer '{hit}'.",
|
| 262 |
+
)
|
| 263 |
+
for missed in result.missed:
|
| 264 |
+
rubric.add(
|
| 265 |
+
name=f"consumer_missed:{missed}",
|
| 266 |
+
score=MISSED_CONSUMER_PENALTY,
|
| 267 |
+
explanation=f"Missed affected consumer '{missed}'.",
|
| 268 |
+
)
|
| 269 |
+
for flagged in result.false_flags:
|
| 270 |
+
rubric.add(
|
| 271 |
+
name=f"consumer_false_flag:{flagged}",
|
| 272 |
+
score=FALSE_FLAG_PENALTY,
|
| 273 |
+
explanation=(
|
| 274 |
+
f"False-flagged unaffected consumer '{flagged}'."
|
| 275 |
+
),
|
| 276 |
+
)
|
| 277 |
+
for unknown in result.unknown_services:
|
| 278 |
+
rubric.add(
|
| 279 |
+
name=f"unknown_service:{unknown}",
|
| 280 |
+
score=UNKNOWN_SERVICE_PENALTY,
|
| 281 |
+
explanation=f"'{unknown}' is not a known service in this graph.",
|
| 282 |
+
)
|
| 283 |
+
return rubric
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def phase2_episode_score(result: ImpactTraceResult) -> float:
|
| 287 |
+
"""Phase 2 final score = F1, clamped to (0.01, 0.99)."""
|
| 288 |
+
return round(max(0.01, min(0.99, result.f1)), 4)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
# ── Phase 3 — fix validation (Rubric API) ────────────────────────────────
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def phase3_fix_rubric(result: FixValidationResult) -> Rubric:
|
| 295 |
+
"""Build a Rubric from a Phase 3 fix-validation result."""
|
| 296 |
+
rubric = Rubric()
|
| 297 |
+
|
| 298 |
+
if not result.is_well_formed:
|
| 299 |
+
rubric.add(
|
| 300 |
+
name="malformed_patch",
|
| 301 |
+
score=MALFORMED_PATCH_PENALTY,
|
| 302 |
+
explanation="; ".join(result.notes) or "Malformed spec patch.",
|
| 303 |
+
)
|
| 304 |
+
return rubric
|
| 305 |
+
|
| 306 |
+
if not result.is_strategy_acceptable:
|
| 307 |
+
rubric.add(
|
| 308 |
+
name="strategy_unacceptable",
|
| 309 |
+
score=UNACCEPTABLE_STRATEGY_PENALTY,
|
| 310 |
+
explanation=(
|
| 311 |
+
f"Strategy '{result.strategy}' is not appropriate for this "
|
| 312 |
+
f"scenario."
|
| 313 |
+
),
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
if result.all_consumers_pass:
|
| 317 |
+
rubric.add(
|
| 318 |
+
name="fix_passes_all_consumers",
|
| 319 |
+
score=FIX_PASSES_ALL_REWARD,
|
| 320 |
+
explanation=(
|
| 321 |
+
f"Fix using strategy '{result.strategy}' validates against "
|
| 322 |
+
f"all {len(result.consumers_passing)} consumer(s)."
|
| 323 |
+
),
|
| 324 |
+
)
|
| 325 |
+
else:
|
| 326 |
+
for consumer, reason in result.failure_reasons.items():
|
| 327 |
+
rubric.add(
|
| 328 |
+
name=f"fix_breaks_consumer:{consumer}",
|
| 329 |
+
score=FIX_BREAKS_CONSUMER_PENALTY,
|
| 330 |
+
explanation=(
|
| 331 |
+
f"Fix breaks consumer '{consumer}': {reason}."
|
| 332 |
+
),
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
return rubric
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def phase3_episode_score(result: FixValidationResult) -> float:
|
| 339 |
+
"""Phase 3 final score: 0.99 if all consumers pass else proportional."""
|
| 340 |
+
if not result.is_well_formed:
|
| 341 |
+
return 0.01
|
| 342 |
+
total = len(result.consumers_passing) + len(result.consumers_failing)
|
| 343 |
+
if total == 0:
|
| 344 |
+
return 0.01
|
| 345 |
+
raw = len(result.consumers_passing) / total
|
| 346 |
+
return round(max(0.01, min(0.99, raw)), 4)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ── Cross-cutting signals ────────────────────────────────────────────────
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def malformed_action_component() -> RubricComponent:
|
| 353 |
+
"""Penalty for an action JSON that fails schema validation."""
|
| 354 |
+
return RubricComponent(
|
| 355 |
+
name="malformed_action",
|
| 356 |
+
score=MALFORMED_ACTION_PENALTY,
|
| 357 |
+
explanation="Action did not match the expected schema.",
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def spam_penalty_component(reports: int, planted: int) -> RubricComponent | None:
|
| 362 |
+
"""Anti-hacking: agent reporting > 3× planted violations is spamming."""
|
| 363 |
+
if planted <= 0:
|
| 364 |
+
return None
|
| 365 |
+
if reports > SPAM_THRESHOLD_MULTIPLIER * planted:
|
| 366 |
+
return RubricComponent(
|
| 367 |
+
name="spam_penalty",
|
| 368 |
+
score=SPAM_PENALTY,
|
| 369 |
+
explanation=(
|
| 370 |
+
f"Reported {reports} violations against {planted} planted "
|
| 371 |
+
f"— exceeds {SPAM_THRESHOLD_MULTIPLIER}× threshold."
|
| 372 |
+
),
|
| 373 |
+
)
|
| 374 |
+
return None
|
server/service_graph.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enterprise service graph for Phase 2 / Phase 3 tasks.
|
| 3 |
+
|
| 4 |
+
Defines a small fictional microservice ecosystem inspired by real
|
| 5 |
+
e-commerce platforms: a producer service (UserService) whose API change
|
| 6 |
+
ripples through several consumers (OrdersService, BillingService,
|
| 7 |
+
NotificationsService, AnalyticsETL).
|
| 8 |
+
|
| 9 |
+
Each scenario contains:
|
| 10 |
+
* ``producer_spec_v1`` — original OpenAPI spec
|
| 11 |
+
* ``producer_spec_v2`` — spec after the breaking change
|
| 12 |
+
* ``violation`` — the breaking-change record being analysed
|
| 13 |
+
* ``consumers`` — declarations of which fields each consumer
|
| 14 |
+
depends on plus their own contract specs
|
| 15 |
+
* ``ground_truth_affected`` — names of consumers whose contract is broken
|
| 16 |
+
|
| 17 |
+
The graph is intentionally compact: judges can read the whole graph in
|
| 18 |
+
under a minute, but the dependency structure is rich enough to surface
|
| 19 |
+
multi-hop impact (e.g. AnalyticsETL only breaks because BillingService
|
| 20 |
+
forwards a renamed field).
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from typing import Any, Dict, List, Optional
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ── Data classes ─────────────────────────────────────────────────────────
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class ConsumerDeclaration:
|
| 32 |
+
"""How one consumer depends on the producer."""
|
| 33 |
+
|
| 34 |
+
name: str
|
| 35 |
+
description: str
|
| 36 |
+
fields_consumed: List[str]
|
| 37 |
+
spec_excerpt: Dict[str, Any]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class CascadeScenario:
|
| 42 |
+
"""A complete Phase 2/Phase 3 scenario."""
|
| 43 |
+
|
| 44 |
+
scenario_id: str
|
| 45 |
+
producer_name: str
|
| 46 |
+
producer_spec_v1: Dict[str, Any]
|
| 47 |
+
producer_spec_v2: Dict[str, Any]
|
| 48 |
+
violation: Dict[str, Any]
|
| 49 |
+
consumers: List[ConsumerDeclaration]
|
| 50 |
+
ground_truth_affected: List[str]
|
| 51 |
+
description: str
|
| 52 |
+
acceptable_fix_strategies: List[str] = field(
|
| 53 |
+
default_factory=lambda: [
|
| 54 |
+
"field_alias",
|
| 55 |
+
"version_bump",
|
| 56 |
+
"deprecation_window",
|
| 57 |
+
"dual_write",
|
| 58 |
+
]
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ── Scenario A — UserService renames `email` to `email_address` ──────────
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _scenario_user_email_rename() -> CascadeScenario:
|
| 66 |
+
"""A producer renames a field that three consumers read directly."""
|
| 67 |
+
|
| 68 |
+
producer_v1 = {
|
| 69 |
+
"openapi": "3.0.0",
|
| 70 |
+
"info": {"title": "UserService", "version": "1.0.0"},
|
| 71 |
+
"paths": {
|
| 72 |
+
"/users/{id}": {
|
| 73 |
+
"get": {
|
| 74 |
+
"responses": {
|
| 75 |
+
"200": {
|
| 76 |
+
"content": {
|
| 77 |
+
"application/json": {
|
| 78 |
+
"schema": {
|
| 79 |
+
"type": "object",
|
| 80 |
+
"required": ["id", "email", "created_at"],
|
| 81 |
+
"properties": {
|
| 82 |
+
"id": {"type": "string"},
|
| 83 |
+
"email": {
|
| 84 |
+
"type": "string",
|
| 85 |
+
"format": "email",
|
| 86 |
+
},
|
| 87 |
+
"name": {"type": "string"},
|
| 88 |
+
"created_at": {
|
| 89 |
+
"type": "string",
|
| 90 |
+
"format": "date-time",
|
| 91 |
+
},
|
| 92 |
+
},
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
},
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
producer_v2 = {
|
| 104 |
+
"openapi": "3.0.0",
|
| 105 |
+
"info": {"title": "UserService", "version": "2.0.0"},
|
| 106 |
+
"paths": {
|
| 107 |
+
"/users/{id}": {
|
| 108 |
+
"get": {
|
| 109 |
+
"responses": {
|
| 110 |
+
"200": {
|
| 111 |
+
"content": {
|
| 112 |
+
"application/json": {
|
| 113 |
+
"schema": {
|
| 114 |
+
"type": "object",
|
| 115 |
+
"required": [
|
| 116 |
+
"id",
|
| 117 |
+
"email_address",
|
| 118 |
+
"created_at",
|
| 119 |
+
],
|
| 120 |
+
"properties": {
|
| 121 |
+
"id": {"type": "string"},
|
| 122 |
+
"email_address": {
|
| 123 |
+
"type": "string",
|
| 124 |
+
"format": "email",
|
| 125 |
+
},
|
| 126 |
+
"name": {"type": "string"},
|
| 127 |
+
"created_at": {
|
| 128 |
+
"type": "string",
|
| 129 |
+
"format": "date-time",
|
| 130 |
+
},
|
| 131 |
+
},
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
},
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
violation = {
|
| 143 |
+
"field_path": "GET /users/{id}.email",
|
| 144 |
+
"violation_type": "breaking_change",
|
| 145 |
+
"description": (
|
| 146 |
+
"Field 'email' was renamed to 'email_address' and the original "
|
| 147 |
+
"'email' was removed from required fields and properties."
|
| 148 |
+
),
|
| 149 |
+
"from": "email",
|
| 150 |
+
"to": "email_address",
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
consumers = [
|
| 154 |
+
ConsumerDeclaration(
|
| 155 |
+
name="OrdersService",
|
| 156 |
+
description="Reads user.email to attach customer email to orders.",
|
| 157 |
+
fields_consumed=["id", "email"],
|
| 158 |
+
spec_excerpt={
|
| 159 |
+
"expects": {
|
| 160 |
+
"id": {"type": "string"},
|
| 161 |
+
"email": {"type": "string", "format": "email"},
|
| 162 |
+
}
|
| 163 |
+
},
|
| 164 |
+
),
|
| 165 |
+
ConsumerDeclaration(
|
| 166 |
+
name="BillingService",
|
| 167 |
+
description=(
|
| 168 |
+
"Reads user.email to send invoices; forwards email to "
|
| 169 |
+
"AnalyticsETL through its own response."
|
| 170 |
+
),
|
| 171 |
+
fields_consumed=["id", "email"],
|
| 172 |
+
spec_excerpt={
|
| 173 |
+
"expects": {
|
| 174 |
+
"id": {"type": "string"},
|
| 175 |
+
"email": {"type": "string", "format": "email"},
|
| 176 |
+
}
|
| 177 |
+
},
|
| 178 |
+
),
|
| 179 |
+
ConsumerDeclaration(
|
| 180 |
+
name="NotificationsService",
|
| 181 |
+
description="Sends transactional emails to user.email.",
|
| 182 |
+
fields_consumed=["email"],
|
| 183 |
+
spec_excerpt={
|
| 184 |
+
"expects": {
|
| 185 |
+
"email": {"type": "string", "format": "email"},
|
| 186 |
+
}
|
| 187 |
+
},
|
| 188 |
+
),
|
| 189 |
+
ConsumerDeclaration(
|
| 190 |
+
name="AnalyticsETL",
|
| 191 |
+
description=(
|
| 192 |
+
"Reads only id and created_at from UserService directly. "
|
| 193 |
+
"(Tempting false-flag — does NOT consume 'email'.)"
|
| 194 |
+
),
|
| 195 |
+
fields_consumed=["id", "created_at"],
|
| 196 |
+
spec_excerpt={
|
| 197 |
+
"expects": {
|
| 198 |
+
"id": {"type": "string"},
|
| 199 |
+
"created_at": {"type": "string", "format": "date-time"},
|
| 200 |
+
}
|
| 201 |
+
},
|
| 202 |
+
),
|
| 203 |
+
]
|
| 204 |
+
|
| 205 |
+
return CascadeScenario(
|
| 206 |
+
scenario_id="user_email_rename",
|
| 207 |
+
producer_name="UserService",
|
| 208 |
+
producer_spec_v1=producer_v1,
|
| 209 |
+
producer_spec_v2=producer_v2,
|
| 210 |
+
violation=violation,
|
| 211 |
+
consumers=consumers,
|
| 212 |
+
ground_truth_affected=[
|
| 213 |
+
"OrdersService",
|
| 214 |
+
"BillingService",
|
| 215 |
+
"NotificationsService",
|
| 216 |
+
],
|
| 217 |
+
description=(
|
| 218 |
+
"UserService renamed 'email' to 'email_address'. Identify which "
|
| 219 |
+
"downstream services break, and propose a fix that keeps all "
|
| 220 |
+
"consumers working without forcing them to redeploy."
|
| 221 |
+
),
|
| 222 |
+
acceptable_fix_strategies=[
|
| 223 |
+
"field_alias",
|
| 224 |
+
"version_bump",
|
| 225 |
+
"deprecation_window",
|
| 226 |
+
"dual_write",
|
| 227 |
+
],
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ── Scenario B — OrdersService narrows `status` enum ─────────────────────
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _scenario_orders_status_narrowed() -> CascadeScenario:
|
| 235 |
+
"""A producer removes enum values that two consumers still emit."""
|
| 236 |
+
|
| 237 |
+
producer_v1 = {
|
| 238 |
+
"openapi": "3.0.0",
|
| 239 |
+
"info": {"title": "OrdersService", "version": "1.0.0"},
|
| 240 |
+
"paths": {
|
| 241 |
+
"/orders/{id}/status": {
|
| 242 |
+
"put": {
|
| 243 |
+
"requestBody": {
|
| 244 |
+
"content": {
|
| 245 |
+
"application/json": {
|
| 246 |
+
"schema": {
|
| 247 |
+
"type": "object",
|
| 248 |
+
"required": ["status"],
|
| 249 |
+
"properties": {
|
| 250 |
+
"status": {
|
| 251 |
+
"type": "string",
|
| 252 |
+
"enum": [
|
| 253 |
+
"pending",
|
| 254 |
+
"confirmed",
|
| 255 |
+
"shipped",
|
| 256 |
+
"delivered",
|
| 257 |
+
"cancelled",
|
| 258 |
+
"refunded",
|
| 259 |
+
],
|
| 260 |
+
}
|
| 261 |
+
},
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
},
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
producer_v2 = {
|
| 272 |
+
"openapi": "3.0.0",
|
| 273 |
+
"info": {"title": "OrdersService", "version": "2.0.0"},
|
| 274 |
+
"paths": {
|
| 275 |
+
"/orders/{id}/status": {
|
| 276 |
+
"put": {
|
| 277 |
+
"requestBody": {
|
| 278 |
+
"content": {
|
| 279 |
+
"application/json": {
|
| 280 |
+
"schema": {
|
| 281 |
+
"type": "object",
|
| 282 |
+
"required": ["status"],
|
| 283 |
+
"properties": {
|
| 284 |
+
"status": {
|
| 285 |
+
"type": "string",
|
| 286 |
+
"enum": [
|
| 287 |
+
"pending",
|
| 288 |
+
"confirmed",
|
| 289 |
+
"shipped",
|
| 290 |
+
"delivered",
|
| 291 |
+
],
|
| 292 |
+
}
|
| 293 |
+
},
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
},
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
violation = {
|
| 304 |
+
"field_path": "PUT /orders/{id}/status.status",
|
| 305 |
+
"violation_type": "breaking_change",
|
| 306 |
+
"description": (
|
| 307 |
+
"Enum narrowed: 'cancelled' and 'refunded' were removed from "
|
| 308 |
+
"the allowed status values."
|
| 309 |
+
),
|
| 310 |
+
"removed_values": ["cancelled", "refunded"],
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
consumers = [
|
| 314 |
+
ConsumerDeclaration(
|
| 315 |
+
name="ReturnsService",
|
| 316 |
+
description=(
|
| 317 |
+
"Sets status='refunded' when processing a return. Will "
|
| 318 |
+
"fail on v2 because 'refunded' is no longer accepted."
|
| 319 |
+
),
|
| 320 |
+
fields_consumed=["status"],
|
| 321 |
+
spec_excerpt={
|
| 322 |
+
"emits": {"status": {"enum": ["refunded"]}}
|
| 323 |
+
},
|
| 324 |
+
),
|
| 325 |
+
ConsumerDeclaration(
|
| 326 |
+
name="SupportPortal",
|
| 327 |
+
description=(
|
| 328 |
+
"Allows agents to mark orders as 'cancelled'. Will fail on "
|
| 329 |
+
"v2 because 'cancelled' is no longer accepted."
|
| 330 |
+
),
|
| 331 |
+
fields_consumed=["status"],
|
| 332 |
+
spec_excerpt={
|
| 333 |
+
"emits": {"status": {"enum": ["cancelled"]}}
|
| 334 |
+
},
|
| 335 |
+
),
|
| 336 |
+
ConsumerDeclaration(
|
| 337 |
+
name="ShippingService",
|
| 338 |
+
description=(
|
| 339 |
+
"Only emits 'shipped' and 'delivered' — both still valid. "
|
| 340 |
+
"(False-flag candidate.)"
|
| 341 |
+
),
|
| 342 |
+
fields_consumed=["status"],
|
| 343 |
+
spec_excerpt={
|
| 344 |
+
"emits": {"status": {"enum": ["shipped", "delivered"]}}
|
| 345 |
+
},
|
| 346 |
+
),
|
| 347 |
+
]
|
| 348 |
+
|
| 349 |
+
return CascadeScenario(
|
| 350 |
+
scenario_id="orders_status_narrowed",
|
| 351 |
+
producer_name="OrdersService",
|
| 352 |
+
producer_spec_v1=producer_v1,
|
| 353 |
+
producer_spec_v2=producer_v2,
|
| 354 |
+
violation=violation,
|
| 355 |
+
consumers=consumers,
|
| 356 |
+
ground_truth_affected=["ReturnsService", "SupportPortal"],
|
| 357 |
+
description=(
|
| 358 |
+
"OrdersService narrowed the order status enum, removing "
|
| 359 |
+
"'cancelled' and 'refunded'. Identify which consumers can no "
|
| 360 |
+
"longer call this endpoint."
|
| 361 |
+
),
|
| 362 |
+
acceptable_fix_strategies=[
|
| 363 |
+
"version_bump",
|
| 364 |
+
"deprecation_window",
|
| 365 |
+
"consumer_patch",
|
| 366 |
+
],
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# ── Public registry ───────────────────────────────────────────────────────
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
_SCENARIOS: Dict[str, CascadeScenario] = {
|
| 374 |
+
"user_email_rename": _scenario_user_email_rename(),
|
| 375 |
+
"orders_status_narrowed": _scenario_orders_status_narrowed(),
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def get_cascade_scenario(
|
| 380 |
+
scenario_id: Optional[str] = None,
|
| 381 |
+
seed: Optional[int] = None,
|
| 382 |
+
) -> CascadeScenario:
|
| 383 |
+
"""Return a cascade scenario by id, or pick one deterministically by seed.
|
| 384 |
+
|
| 385 |
+
Parameters
|
| 386 |
+
----------
|
| 387 |
+
scenario_id:
|
| 388 |
+
Explicit scenario name. Takes precedence over ``seed``.
|
| 389 |
+
seed:
|
| 390 |
+
If provided (and ``scenario_id`` is not), selects ``user_email_rename``
|
| 391 |
+
for even seeds and ``orders_status_narrowed`` for odd seeds.
|
| 392 |
+
|
| 393 |
+
Returns
|
| 394 |
+
-------
|
| 395 |
+
CascadeScenario
|
| 396 |
+
The (immutable) selected scenario.
|
| 397 |
+
"""
|
| 398 |
+
if scenario_id is not None:
|
| 399 |
+
if scenario_id not in _SCENARIOS:
|
| 400 |
+
raise ValueError(
|
| 401 |
+
f"Unknown cascade scenario '{scenario_id}'. "
|
| 402 |
+
f"Available: {list(_SCENARIOS)}"
|
| 403 |
+
)
|
| 404 |
+
return _SCENARIOS[scenario_id]
|
| 405 |
+
|
| 406 |
+
keys = sorted(_SCENARIOS.keys())
|
| 407 |
+
if seed is None:
|
| 408 |
+
return _SCENARIOS[keys[0]]
|
| 409 |
+
return _SCENARIOS[keys[seed % len(keys)]]
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def public_observation(scenario: CascadeScenario) -> Dict[str, Any]:
|
| 413 |
+
"""Return the portion of the scenario the agent is allowed to see.
|
| 414 |
+
|
| 415 |
+
The ground-truth ``ground_truth_affected`` list is held back so the
|
| 416 |
+
agent must reason about impact from the consumer declarations rather
|
| 417 |
+
than read the answer.
|
| 418 |
+
"""
|
| 419 |
+
return {
|
| 420 |
+
"producer": scenario.producer_name,
|
| 421 |
+
"producer_spec_v1": scenario.producer_spec_v1,
|
| 422 |
+
"producer_spec_v2": scenario.producer_spec_v2,
|
| 423 |
+
"violation": scenario.violation,
|
| 424 |
+
"consumers": [
|
| 425 |
+
{
|
| 426 |
+
"name": c.name,
|
| 427 |
+
"description": c.description,
|
| 428 |
+
"fields_consumed": c.fields_consumed,
|
| 429 |
+
"spec_excerpt": c.spec_excerpt,
|
| 430 |
+
}
|
| 431 |
+
for c in scenario.consumers
|
| 432 |
+
],
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def consumer_specs_for_fix(scenario: CascadeScenario) -> Dict[str, Any]:
|
| 437 |
+
"""Return only the consumer specs needed for Phase 3 fix validation."""
|
| 438 |
+
return {
|
| 439 |
+
c.name: {
|
| 440 |
+
"spec_excerpt": c.spec_excerpt,
|
| 441 |
+
"fields_consumed": c.fields_consumed,
|
| 442 |
+
}
|
| 443 |
+
for c in scenario.consumers
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
CASCADE_SCENARIO_IDS: List[str] = sorted(_SCENARIOS.keys())
|
tests/test_environment.py
CHANGED
|
@@ -214,3 +214,153 @@ def test_auth_task_variants_differ():
|
|
| 214 |
def test_easy_pool_has_twelve_variants():
|
| 215 |
from server.spec_generator import _EASY_POOL
|
| 216 |
assert len(_EASY_POOL) == 12, f"Expected 12 pool entries, got {len(_EASY_POOL)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
def test_easy_pool_has_twelve_variants():
|
| 215 |
from server.spec_generator import _EASY_POOL
|
| 216 |
assert len(_EASY_POOL) == 12, f"Expected 12 pool entries, got {len(_EASY_POOL)}"
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ── Phase 2 — impact tracing ───────────────────────────────────────────────
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def test_phase2_reset_returns_service_graph(env):
|
| 223 |
+
obs = env.reset(task_name="trace_downstream_blast_radius", seed=1)
|
| 224 |
+
assert obs.phase == "tracing"
|
| 225 |
+
assert obs.total_consumers >= 3
|
| 226 |
+
assert "consumers" in obs.service_graph
|
| 227 |
+
assert obs.feedback
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def test_phase2_perfect_trace_scores_high(env):
|
| 231 |
+
env.reset(task_name="trace_downstream_blast_radius", seed=1)
|
| 232 |
+
action = ValidatorAction(
|
| 233 |
+
action_type="trace_impact",
|
| 234 |
+
affected_services=[
|
| 235 |
+
"OrdersService",
|
| 236 |
+
"BillingService",
|
| 237 |
+
"NotificationsService",
|
| 238 |
+
],
|
| 239 |
+
)
|
| 240 |
+
result = env.step(action)
|
| 241 |
+
assert result.done is True
|
| 242 |
+
assert result.reward > 2.0 # 3 hits @ +0.8 each
|
| 243 |
+
assert env.state.score > 0.9
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def test_phase2_false_flag_penalty(env):
|
| 247 |
+
env.reset(task_name="trace_downstream_blast_radius", seed=1)
|
| 248 |
+
action = ValidatorAction(
|
| 249 |
+
action_type="trace_impact",
|
| 250 |
+
affected_services=["OrdersService", "AnalyticsETL"], # one false flag
|
| 251 |
+
)
|
| 252 |
+
result = env.step(action)
|
| 253 |
+
assert result.done is True
|
| 254 |
+
# 1 hit (+0.8) + 2 missed (-0.5 each) + 1 false (-0.4) = -0.6
|
| 255 |
+
assert result.reward < 0
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def test_phase2_unknown_service_treated_as_false_flag(env):
|
| 259 |
+
env.reset(task_name="trace_downstream_blast_radius", seed=1)
|
| 260 |
+
action = ValidatorAction(
|
| 261 |
+
action_type="trace_impact",
|
| 262 |
+
affected_services=["NonexistentService"],
|
| 263 |
+
)
|
| 264 |
+
result = env.step(action)
|
| 265 |
+
assert result.done is True
|
| 266 |
+
assert result.reward < 0
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# ── Phase 3 — fix proposal ─────────────────────────────────────────────────
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def test_phase3_reset_returns_violation_and_consumers(env):
|
| 273 |
+
obs = env.reset(task_name="propose_backward_compat_fix", seed=1)
|
| 274 |
+
assert obs.phase == "fix_proposal"
|
| 275 |
+
assert obs.detected_violation
|
| 276 |
+
assert obs.consumer_specs
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def test_phase3_good_field_alias_passes_all_consumers(env):
|
| 280 |
+
env.reset(task_name="propose_backward_compat_fix", seed=1)
|
| 281 |
+
action = ValidatorAction(
|
| 282 |
+
action_type="propose_fix",
|
| 283 |
+
fix_strategy="field_alias",
|
| 284 |
+
spec_patch={"aliases": {"email": "email_address"}},
|
| 285 |
+
rationale="Keep old field name as alias",
|
| 286 |
+
)
|
| 287 |
+
result = env.step(action)
|
| 288 |
+
assert result.done is True
|
| 289 |
+
assert result.reward >= 2.0
|
| 290 |
+
assert env.state.fix_validated is True
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def test_phase3_malformed_strategy_penalty(env):
|
| 294 |
+
env.reset(task_name="propose_backward_compat_fix", seed=1)
|
| 295 |
+
action = ValidatorAction(
|
| 296 |
+
action_type="propose_fix",
|
| 297 |
+
fix_strategy="not_a_real_strategy",
|
| 298 |
+
spec_patch={},
|
| 299 |
+
)
|
| 300 |
+
result = env.step(action)
|
| 301 |
+
assert result.reward < 0
|
| 302 |
+
assert env.state.fix_validated is False
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def test_phase3_breaking_consumer_penalised(env):
|
| 306 |
+
env.reset(task_name="propose_backward_compat_fix", seed=1)
|
| 307 |
+
# dual_write but missing the new field — breaks all consumers
|
| 308 |
+
action = ValidatorAction(
|
| 309 |
+
action_type="propose_fix",
|
| 310 |
+
fix_strategy="dual_write",
|
| 311 |
+
spec_patch={"emit_fields": ["email"]},
|
| 312 |
+
)
|
| 313 |
+
result = env.step(action)
|
| 314 |
+
assert result.reward < 0
|
| 315 |
+
assert env.state.fix_validated is False
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# ── Cascade — full workflow ───────────────────────────────────────────────
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def test_cascade_starts_in_tracing_phase(env):
|
| 322 |
+
obs = env.reset(task_name="multi_service_cascade_fix", seed=1)
|
| 323 |
+
assert obs.phase == "tracing"
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def test_cascade_transitions_to_fix_after_correct_trace(env):
|
| 327 |
+
env.reset(task_name="multi_service_cascade_fix", seed=1)
|
| 328 |
+
trace = ValidatorAction(
|
| 329 |
+
action_type="trace_impact",
|
| 330 |
+
affected_services=[
|
| 331 |
+
"OrdersService",
|
| 332 |
+
"BillingService",
|
| 333 |
+
"NotificationsService",
|
| 334 |
+
],
|
| 335 |
+
)
|
| 336 |
+
obs = env.step(trace)
|
| 337 |
+
assert obs.done is False
|
| 338 |
+
assert obs.phase == "fix_proposal"
|
| 339 |
+
|
| 340 |
+
fix = ValidatorAction(
|
| 341 |
+
action_type="propose_fix",
|
| 342 |
+
fix_strategy="field_alias",
|
| 343 |
+
spec_patch={"aliases": {"email": "email_address"}},
|
| 344 |
+
)
|
| 345 |
+
obs = env.step(fix)
|
| 346 |
+
assert obs.done is True
|
| 347 |
+
assert env.state.fix_validated is True
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# ── Determinism ────────────────────────────────────────────────────────────
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def test_phase2_seed_determinism(env):
|
| 354 |
+
obs1 = env.reset(task_name="trace_downstream_blast_radius", seed=1)
|
| 355 |
+
obs2 = env.reset(task_name="trace_downstream_blast_radius", seed=1)
|
| 356 |
+
services1 = sorted(c["name"] for c in obs1.service_graph["consumers"])
|
| 357 |
+
services2 = sorted(c["name"] for c in obs2.service_graph["consumers"])
|
| 358 |
+
assert services1 == services2
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def test_different_seeds_pick_different_scenarios(env):
|
| 362 |
+
obs_even = env.reset(task_name="trace_downstream_blast_radius", seed=0)
|
| 363 |
+
obs_odd = env.reset(task_name="trace_downstream_blast_radius", seed=1)
|
| 364 |
+
name_even = obs_even.service_graph["producer"]
|
| 365 |
+
name_odd = obs_odd.service_graph["producer"]
|
| 366 |
+
assert name_even != name_odd
|
training/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Training — Enterprise Contract Guardian
|
| 2 |
+
|
| 3 |
+
Re-runnable training pipeline for the API Contract Validator environment, using GRPO from TRL with LoRA adapters via Unsloth.
|
| 4 |
+
|
| 5 |
+
## Files
|
| 6 |
+
|
| 7 |
+
| File | Purpose |
|
| 8 |
+
|---|---|
|
| 9 |
+
| `baseline.py` | Run the untrained model on every task; write `baseline_scores.json` |
|
| 10 |
+
| `train.py` | GRPO training loop — connects to env, rolls out, trains LoRA |
|
| 11 |
+
| `plot.py` | Build `reward_curve.png` and `before_after.png` for the README |
|
| 12 |
+
| `grpo_colab.ipynb` | One-click Colab notebook (open in Colab → Runtime → Run all) |
|
| 13 |
+
|
| 14 |
+
## Three ways to run training
|
| 15 |
+
|
| 16 |
+
### A. Colab notebook (easiest, free GPU)
|
| 17 |
+
|
| 18 |
+
Open `grpo_colab.ipynb` in Colab. Set `HF_TOKEN` and `WANDB_API_KEY` in the secrets pane. Hit **Runtime → Run all**.
|
| 19 |
+
|
| 20 |
+
### B. HF Jobs (best for the onsite — uses your $30 credit)
|
| 21 |
+
|
| 22 |
+
```bash
|
| 23 |
+
hf jobs uv run \
|
| 24 |
+
--with trl --with unsloth --with openenv-core --with wandb \
|
| 25 |
+
--flavor t4-small \
|
| 26 |
+
-s HF_TOKEN -s WANDB_API_KEY \
|
| 27 |
+
-- python training/train.py
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
### C. Local GPU
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
pip install trl unsloth wandb matplotlib datasets
|
| 34 |
+
export HF_TOKEN="hf_..."
|
| 35 |
+
export WANDB_API_KEY="..."
|
| 36 |
+
export ENV_URL="http://localhost:7860" # or your HF Space URL
|
| 37 |
+
|
| 38 |
+
# 1. Start the env server in another terminal
|
| 39 |
+
uvicorn server.app:app --host 0.0.0.0 --port 7860
|
| 40 |
+
|
| 41 |
+
# 2. Baseline
|
| 42 |
+
python training/baseline.py
|
| 43 |
+
|
| 44 |
+
# 3. Train
|
| 45 |
+
python training/train.py
|
| 46 |
+
|
| 47 |
+
# 4. Inference with trained adapter
|
| 48 |
+
export MODEL_NAME="<your-username>/api-contract-validator-grpo"
|
| 49 |
+
export SCORES_OUT_PATH="trained_scores.json"
|
| 50 |
+
python inference.py
|
| 51 |
+
|
| 52 |
+
# 5. Plots
|
| 53 |
+
python training/plot.py
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## Key environment variables
|
| 57 |
+
|
| 58 |
+
| Variable | Default | Notes |
|
| 59 |
+
|---|---|---|
|
| 60 |
+
| `HF_TOKEN` | — | Required. Used for both inference (router) and Hub push |
|
| 61 |
+
| `WANDB_API_KEY` | — | Optional. If set, training logs go to WandB |
|
| 62 |
+
| `BASE_MODEL` | `unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit` | Small enough for T4 |
|
| 63 |
+
| `ENV_URL` | `http://localhost:7860` | Local server or deployed HF Space |
|
| 64 |
+
| `MAX_STEPS` | `200` | GRPO steps. ~45 min on T4 |
|
| 65 |
+
| `NUM_GENERATIONS` | `4` | Completions per prompt for relative ranking |
|
| 66 |
+
| `LORA_R` | `16` | LoRA rank |
|
| 67 |
+
| `PUSH_TO_HUB` | — | `<username>/<repo>` — push trained adapter |
|
| 68 |
+
|
| 69 |
+
## What the judges look at
|
| 70 |
+
|
| 71 |
+
After running, **commit** these to the repo:
|
| 72 |
+
|
| 73 |
+
```
|
| 74 |
+
baseline_scores.json # repo root
|
| 75 |
+
trained_scores.json # repo root
|
| 76 |
+
api_contract_validator/results/reward_curve.png
|
| 77 |
+
api_contract_validator/results/before_after.png
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
The README's "Training Results" section reads from these files. The plots are evidence of the "Improvement in Rewards" 20% criterion.
|
training/baseline.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Baseline runner — measure the untrained model's score on every task.
|
| 3 |
+
|
| 4 |
+
Writes two artefacts that the finale judges look at:
|
| 5 |
+
|
| 6 |
+
baseline_scores.json — top-level scores per task (committed to repo)
|
| 7 |
+
api_contract_validator/results/baseline_table.md — markdown table for README
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
|
| 11 |
+
# Make sure the env server is up first
|
| 12 |
+
docker run -d -p 7860:7860 --name baseline-env api-contract-validator
|
| 13 |
+
|
| 14 |
+
# Then run from the api_contract_validator/ directory
|
| 15 |
+
export HF_TOKEN="hf_xxxxx"
|
| 16 |
+
export API_BASE_URL="https://router.huggingface.co/v1"
|
| 17 |
+
export MODEL_NAME="Qwen/Qwen2.5-72B-Instruct"
|
| 18 |
+
python training/baseline.py
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import sys
|
| 25 |
+
from datetime import datetime, timezone
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
# Make api_contract_validator importable when this script is run directly
|
| 29 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 30 |
+
sys.path.insert(0, str(ROOT))
|
| 31 |
+
|
| 32 |
+
# Load .env from api_contract_validator/ before reading os.getenv values
|
| 33 |
+
try:
|
| 34 |
+
from dotenv import load_dotenv
|
| 35 |
+
_ENV_FILE = ROOT / ".env"
|
| 36 |
+
if _ENV_FILE.exists():
|
| 37 |
+
load_dotenv(_ENV_FILE)
|
| 38 |
+
except ImportError:
|
| 39 |
+
pass
|
| 40 |
+
|
| 41 |
+
from openai import OpenAI # noqa: E402
|
| 42 |
+
|
| 43 |
+
from client import ValidatorEnv # noqa: E402
|
| 44 |
+
from inference import ( # noqa: E402
|
| 45 |
+
BENCHMARK,
|
| 46 |
+
TASKS,
|
| 47 |
+
MODEL_NAME,
|
| 48 |
+
HF_TOKEN,
|
| 49 |
+
API_BASE_URL,
|
| 50 |
+
LOCAL_IMAGE_NAME,
|
| 51 |
+
run_single_task,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
OUT_PATH = Path(os.getenv("BASELINE_OUT", ROOT.parent / "baseline_scores.json"))
|
| 56 |
+
TABLE_PATH = ROOT / "results" / "baseline_table.md"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def main() -> None:
|
| 60 |
+
if not HF_TOKEN:
|
| 61 |
+
sys.exit("HF_TOKEN not set. Export it before running.")
|
| 62 |
+
|
| 63 |
+
openai_client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 64 |
+
|
| 65 |
+
if LOCAL_IMAGE_NAME:
|
| 66 |
+
env = await ValidatorEnv.from_docker_image(LOCAL_IMAGE_NAME)
|
| 67 |
+
else:
|
| 68 |
+
env_url = os.getenv("ENV_BASE_URL", "http://localhost:7860")
|
| 69 |
+
env = ValidatorEnv(base_url=env_url)
|
| 70 |
+
|
| 71 |
+
results = []
|
| 72 |
+
try:
|
| 73 |
+
for task in TASKS:
|
| 74 |
+
res = await run_single_task(openai_client, env, task)
|
| 75 |
+
results.append(res)
|
| 76 |
+
finally:
|
| 77 |
+
try:
|
| 78 |
+
await env.close()
|
| 79 |
+
except Exception:
|
| 80 |
+
pass
|
| 81 |
+
|
| 82 |
+
out = {
|
| 83 |
+
"model": MODEL_NAME,
|
| 84 |
+
"benchmark": BENCHMARK,
|
| 85 |
+
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
| 86 |
+
"scores": {r["task"]: r["score"] for r in results},
|
| 87 |
+
"details": results,
|
| 88 |
+
}
|
| 89 |
+
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 90 |
+
OUT_PATH.write_text(json.dumps(out, indent=2))
|
| 91 |
+
print(f"[INFO] wrote {OUT_PATH}", flush=True)
|
| 92 |
+
|
| 93 |
+
# Markdown table for README embedding
|
| 94 |
+
lines = [
|
| 95 |
+
"| Task | Score | Steps | Success |",
|
| 96 |
+
"|---|---|---|---|",
|
| 97 |
+
]
|
| 98 |
+
for r in results:
|
| 99 |
+
lines.append(
|
| 100 |
+
f"| `{r['task']}` | {r['score']:.2f} | {r['steps']} | "
|
| 101 |
+
f"{'✅' if r['success'] else '⛔'} |"
|
| 102 |
+
)
|
| 103 |
+
TABLE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 104 |
+
TABLE_PATH.write_text("\n".join(lines) + "\n")
|
| 105 |
+
print(f"[INFO] wrote {TABLE_PATH}", flush=True)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
if __name__ == "__main__":
|
| 109 |
+
asyncio.run(main())
|
training/grpo_colab.ipynb
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {},
|
| 6 |
+
"source": [
|
| 7 |
+
"# Enterprise Contract Guardian — GRPO Training\n",
|
| 8 |
+
"\n",
|
| 9 |
+
"**Meta PyTorch OpenEnv Hackathon × Scaler School of Technology — Grand Finale**\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"This notebook trains a small open-weight model (Qwen2.5-1.5B by default) on the API Contract Validator environment using GRPO from TRL. The reward signal comes directly from the deployed environment, not from a static dataset — the model learns by interacting with the env on every training step.\n",
|
| 12 |
+
"\n",
|
| 13 |
+
"**Hardware**: T4 GPU (15 GB VRAM) is enough. Colab free tier or HF Jobs `--flavor t4-small` both work.\n",
|
| 14 |
+
"\n",
|
| 15 |
+
"**Estimated runtime**: ~45 min for 200 steps on Qwen2.5-1.5B at LoRA r=16."
|
| 16 |
+
]
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"cell_type": "markdown",
|
| 20 |
+
"metadata": {},
|
| 21 |
+
"source": [
|
| 22 |
+
"## 1. Install dependencies"
|
| 23 |
+
]
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"cell_type": "code",
|
| 27 |
+
"execution_count": null,
|
| 28 |
+
"metadata": {},
|
| 29 |
+
"outputs": [],
|
| 30 |
+
"source": [
|
| 31 |
+
"!pip install -q --upgrade pip\n",
|
| 32 |
+
"!pip install -q openenv-core==0.2.2 trl unsloth wandb matplotlib datasets\n",
|
| 33 |
+
"!pip install -q --no-deps bitsandbytes triton xformers"
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"cell_type": "markdown",
|
| 38 |
+
"metadata": {},
|
| 39 |
+
"source": [
|
| 40 |
+
"## 2. Authenticate to HuggingFace and WandB"
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"cell_type": "code",
|
| 45 |
+
"execution_count": null,
|
| 46 |
+
"metadata": {},
|
| 47 |
+
"outputs": [],
|
| 48 |
+
"source": [
|
| 49 |
+
"import os\n",
|
| 50 |
+
"from huggingface_hub import login as hf_login\n",
|
| 51 |
+
"import wandb\n",
|
| 52 |
+
"\n",
|
| 53 |
+
"# Either paste your tokens here, or `Runtime → Secrets` in Colab\n",
|
| 54 |
+
"os.environ['HF_TOKEN'] = os.getenv('HF_TOKEN') or 'hf_paste_yours_here'\n",
|
| 55 |
+
"os.environ['WANDB_API_KEY'] = os.getenv('WANDB_API_KEY') or 'paste_yours_here'\n",
|
| 56 |
+
"\n",
|
| 57 |
+
"hf_login(token=os.environ['HF_TOKEN'])\n",
|
| 58 |
+
"wandb.login(key=os.environ['WANDB_API_KEY'])"
|
| 59 |
+
]
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"cell_type": "markdown",
|
| 63 |
+
"metadata": {},
|
| 64 |
+
"source": [
|
| 65 |
+
"## 3. Clone the project repo and start the env server\n",
|
| 66 |
+
"\n",
|
| 67 |
+
"We start a local FastAPI server in the background. If you already have an HF Space deployed, set `ENV_URL` to the Space URL instead and skip the server start."
|
| 68 |
+
]
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"cell_type": "code",
|
| 72 |
+
"execution_count": null,
|
| 73 |
+
"metadata": {},
|
| 74 |
+
"outputs": [],
|
| 75 |
+
"source": [
|
| 76 |
+
"!git clone https://github.com/kumarpushpam17-personal/Hackathon hack && cd hack/api_contract_validator && pip install -e .\n",
|
| 77 |
+
"%cd hack/api_contract_validator\n",
|
| 78 |
+
"\n",
|
| 79 |
+
"# Start the env server in the background\n",
|
| 80 |
+
"import subprocess, time\n",
|
| 81 |
+
"server = subprocess.Popen(\n",
|
| 82 |
+
" ['uvicorn', 'server.app:app', '--host', '0.0.0.0', '--port', '7860'],\n",
|
| 83 |
+
" stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n",
|
| 84 |
+
")\n",
|
| 85 |
+
"time.sleep(8)\n",
|
| 86 |
+
"!curl -s http://localhost:7860/health"
|
| 87 |
+
]
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"cell_type": "markdown",
|
| 91 |
+
"metadata": {},
|
| 92 |
+
"source": [
|
| 93 |
+
"## 4. Run the baseline (untrained) to establish before-numbers\n",
|
| 94 |
+
"\n",
|
| 95 |
+
"Writes `baseline_scores.json` at the repo root. This is one of the two files the judges grade the \"Improvement in Rewards\" 20% criterion against."
|
| 96 |
+
]
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"cell_type": "code",
|
| 100 |
+
"execution_count": null,
|
| 101 |
+
"metadata": {},
|
| 102 |
+
"outputs": [],
|
| 103 |
+
"source": [
|
| 104 |
+
"%env API_BASE_URL=https://router.huggingface.co/v1\n",
|
| 105 |
+
"%env MODEL_NAME=Qwen/Qwen2.5-72B-Instruct\n",
|
| 106 |
+
"%env BASELINE_OUT=/content/hack/baseline_scores.json\n",
|
| 107 |
+
"\n",
|
| 108 |
+
"!python training/baseline.py"
|
| 109 |
+
]
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"cell_type": "markdown",
|
| 113 |
+
"metadata": {},
|
| 114 |
+
"source": [
|
| 115 |
+
"## 5. Train with GRPO\n",
|
| 116 |
+
"\n",
|
| 117 |
+
"The reward function rolls out one env step per generated completion and uses the env's grader as the reward. GRPO compares the `num_generations` completions per prompt and pushes the model toward the higher-reward ones."
|
| 118 |
+
]
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
"cell_type": "code",
|
| 122 |
+
"execution_count": null,
|
| 123 |
+
"metadata": {},
|
| 124 |
+
"outputs": [],
|
| 125 |
+
"source": [
|
| 126 |
+
"%env BASE_MODEL=unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit\n",
|
| 127 |
+
"%env ENV_URL=http://localhost:7860\n",
|
| 128 |
+
"%env MAX_STEPS=200\n",
|
| 129 |
+
"%env NUM_GENERATIONS=4\n",
|
| 130 |
+
"%env WANDB_PROJECT=openenv-contract-guardian\n",
|
| 131 |
+
"%env WANDB_RUN=grpo-onsite\n",
|
| 132 |
+
"%env PUSH_TO_HUB=YOUR_USERNAME/api-contract-validator-grpo\n",
|
| 133 |
+
"\n",
|
| 134 |
+
"!python training/train.py"
|
| 135 |
+
]
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"cell_type": "markdown",
|
| 139 |
+
"metadata": {},
|
| 140 |
+
"source": [
|
| 141 |
+
"## 6. Run inference with the trained adapter\n",
|
| 142 |
+
"\n",
|
| 143 |
+
"Set `MODEL_NAME` to the adapter you just pushed, then re-run inference and write `trained_scores.json`."
|
| 144 |
+
]
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"cell_type": "code",
|
| 148 |
+
"execution_count": null,
|
| 149 |
+
"metadata": {},
|
| 150 |
+
"outputs": [],
|
| 151 |
+
"source": [
|
| 152 |
+
"%env MODEL_NAME=YOUR_USERNAME/api-contract-validator-grpo\n",
|
| 153 |
+
"%env SCORES_OUT_PATH=/content/hack/trained_scores.json\n",
|
| 154 |
+
"\n",
|
| 155 |
+
"!python inference.py"
|
| 156 |
+
]
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"cell_type": "markdown",
|
| 160 |
+
"metadata": {},
|
| 161 |
+
"source": [
|
| 162 |
+
"## 7. Generate the plots judges look at\n",
|
| 163 |
+
"\n",
|
| 164 |
+
"Writes `results/reward_curve.png` and `results/before_after.png`. Commit these to the repo — they're embedded in the README results section."
|
| 165 |
+
]
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"cell_type": "code",
|
| 169 |
+
"execution_count": null,
|
| 170 |
+
"metadata": {},
|
| 171 |
+
"outputs": [],
|
| 172 |
+
"source": [
|
| 173 |
+
"!python training/plot.py\n",
|
| 174 |
+
"from IPython.display import Image, display\n",
|
| 175 |
+
"display(Image('results/reward_curve.png'))\n",
|
| 176 |
+
"display(Image('results/before_after.png'))"
|
| 177 |
+
]
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"cell_type": "markdown",
|
| 181 |
+
"metadata": {},
|
| 182 |
+
"source": [
|
| 183 |
+
"## 8. Commit the artefacts back to your fork\n",
|
| 184 |
+
"\n",
|
| 185 |
+
"From your laptop after the run is done:\n",
|
| 186 |
+
"\n",
|
| 187 |
+
"```bash\n",
|
| 188 |
+
"git add baseline_scores.json trained_scores.json \\\n",
|
| 189 |
+
" api_contract_validator/results/reward_curve.png \\\n",
|
| 190 |
+
" api_contract_validator/results/before_after.png\n",
|
| 191 |
+
"git commit -m 'Add baseline, trained scores and reward plots'\n",
|
| 192 |
+
"git push\n",
|
| 193 |
+
"```"
|
| 194 |
+
]
|
| 195 |
+
}
|
| 196 |
+
],
|
| 197 |
+
"metadata": {
|
| 198 |
+
"kernelspec": {
|
| 199 |
+
"display_name": "Python 3",
|
| 200 |
+
"language": "python",
|
| 201 |
+
"name": "python3"
|
| 202 |
+
},
|
| 203 |
+
"language_info": {
|
| 204 |
+
"name": "python",
|
| 205 |
+
"version": "3.10"
|
| 206 |
+
},
|
| 207 |
+
"colab": {
|
| 208 |
+
"provenance": [],
|
| 209 |
+
"gpuType": "T4"
|
| 210 |
+
},
|
| 211 |
+
"accelerator": "GPU"
|
| 212 |
+
},
|
| 213 |
+
"nbformat": 4,
|
| 214 |
+
"nbformat_minor": 5
|
| 215 |
+
}
|
training/plot.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Plot generators for the README results section.
|
| 3 |
+
|
| 4 |
+
Produces two PNG files in ``results/``:
|
| 5 |
+
|
| 6 |
+
reward_curve.png — per-step training reward (from training_state.json)
|
| 7 |
+
before_after.png — per-task baseline vs trained score bar chart
|
| 8 |
+
|
| 9 |
+
Run after both ``baseline_scores.json`` and ``trained_scores.json``
|
| 10 |
+
exist::
|
| 11 |
+
|
| 12 |
+
python training/plot.py
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Dict, List
|
| 21 |
+
|
| 22 |
+
import matplotlib.pyplot as plt
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 27 |
+
REPO_ROOT = ROOT.parent
|
| 28 |
+
RESULTS = ROOT / "results"
|
| 29 |
+
BASELINE = REPO_ROOT / "baseline_scores.json"
|
| 30 |
+
TRAINED = REPO_ROOT / "trained_scores.json"
|
| 31 |
+
TRAIN_STATE = RESULTS / "training_state.json"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _load_scores(path: Path) -> Dict[str, float]:
|
| 35 |
+
if not path.exists():
|
| 36 |
+
sys.exit(f"missing {path}")
|
| 37 |
+
return json.loads(path.read_text())["scores"]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def plot_reward_curve() -> None:
|
| 41 |
+
if not TRAIN_STATE.exists():
|
| 42 |
+
print(f"[WARN] {TRAIN_STATE} not found — skipping reward_curve.png")
|
| 43 |
+
return
|
| 44 |
+
history = json.loads(TRAIN_STATE.read_text())
|
| 45 |
+
rows = [h for h in history if "reward" in h and "step" in h]
|
| 46 |
+
if not rows:
|
| 47 |
+
print("[WARN] no reward entries in training_state.json")
|
| 48 |
+
return
|
| 49 |
+
|
| 50 |
+
steps = [h["step"] for h in rows]
|
| 51 |
+
rewards = [h["reward"] for h in rows]
|
| 52 |
+
|
| 53 |
+
plt.figure(figsize=(8, 5))
|
| 54 |
+
plt.plot(steps, rewards, linewidth=2, color="#2563eb",
|
| 55 |
+
label="GRPO training reward")
|
| 56 |
+
if "loss" in rows[0]:
|
| 57 |
+
plt.twinx().plot(
|
| 58 |
+
steps, [h.get("loss", 0) for h in rows],
|
| 59 |
+
linewidth=1, color="#9ca3af", linestyle="--", label="loss",
|
| 60 |
+
)
|
| 61 |
+
plt.xlabel("Training step")
|
| 62 |
+
plt.ylabel("Mean episode reward")
|
| 63 |
+
plt.title("Enterprise Contract Guardian — GRPO Training")
|
| 64 |
+
plt.grid(alpha=0.3)
|
| 65 |
+
plt.legend(loc="lower right")
|
| 66 |
+
plt.tight_layout()
|
| 67 |
+
out = RESULTS / "reward_curve.png"
|
| 68 |
+
plt.savefig(out, dpi=150)
|
| 69 |
+
plt.close()
|
| 70 |
+
print(f"[INFO] wrote {out}")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def plot_before_after() -> None:
|
| 74 |
+
baseline = _load_scores(BASELINE)
|
| 75 |
+
trained = _load_scores(TRAINED)
|
| 76 |
+
|
| 77 |
+
tasks = sorted(set(baseline) | set(trained))
|
| 78 |
+
base_vals = [baseline.get(t, 0.0) for t in tasks]
|
| 79 |
+
train_vals = [trained.get(t, 0.0) for t in tasks]
|
| 80 |
+
|
| 81 |
+
x = np.arange(len(tasks))
|
| 82 |
+
width = 0.4
|
| 83 |
+
|
| 84 |
+
plt.figure(figsize=(11, 5.5))
|
| 85 |
+
plt.bar(x - width / 2, base_vals, width, label="Baseline (untrained)",
|
| 86 |
+
color="#9ca3af")
|
| 87 |
+
plt.bar(x + width / 2, train_vals, width, label="GRPO-trained",
|
| 88 |
+
color="#16a34a")
|
| 89 |
+
plt.xticks(x, tasks, rotation=30, ha="right", fontsize=9)
|
| 90 |
+
plt.ylabel("Episode score (0–1)")
|
| 91 |
+
plt.ylim(0, 1.0)
|
| 92 |
+
plt.title("Per-task score: baseline vs trained agent")
|
| 93 |
+
plt.legend()
|
| 94 |
+
plt.grid(alpha=0.3, axis="y")
|
| 95 |
+
plt.tight_layout()
|
| 96 |
+
out = RESULTS / "before_after.png"
|
| 97 |
+
plt.savefig(out, dpi=150)
|
| 98 |
+
plt.close()
|
| 99 |
+
print(f"[INFO] wrote {out}")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def main() -> None:
|
| 103 |
+
RESULTS.mkdir(parents=True, exist_ok=True)
|
| 104 |
+
plot_reward_curve()
|
| 105 |
+
plot_before_after()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
if __name__ == "__main__":
|
| 109 |
+
main()
|
training/train.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GRPO training script for the API Contract Validator environment.
|
| 3 |
+
|
| 4 |
+
Designed to be re-runnable by judges from a Colab notebook OR via HF
|
| 5 |
+
Jobs:
|
| 6 |
+
|
| 7 |
+
# HF Jobs (T4 small, ~$0.50/hr — uses your $30 credit)
|
| 8 |
+
hf jobs uv run \
|
| 9 |
+
--with trl --with unsloth --with openenv-core --with wandb \
|
| 10 |
+
--flavor t4-small \
|
| 11 |
+
-s HF_TOKEN -s WANDB_API_KEY \
|
| 12 |
+
-- python training/train.py
|
| 13 |
+
|
| 14 |
+
The script:
|
| 15 |
+
|
| 16 |
+
1. Connects to a deployed HF Space (or local docker) running the env
|
| 17 |
+
2. Loads a small base model with Unsloth 4-bit quantisation
|
| 18 |
+
3. Applies LoRA adapters
|
| 19 |
+
4. Rolls out episodes through the env, collecting (prompt, completion,
|
| 20 |
+
reward) tuples
|
| 21 |
+
5. Trains the LoRA adapters with GRPO from TRL
|
| 22 |
+
6. Logs reward curves to WandB and writes results/reward_curve.png
|
| 23 |
+
7. Pushes the trained adapter to the HuggingFace Hub
|
| 24 |
+
|
| 25 |
+
The reward function uses the env's grader directly — no synthetic
|
| 26 |
+
shaping. This is the key difference from a static-dataset SFT run:
|
| 27 |
+
the model learns from the env's verifiable signal, which is exactly
|
| 28 |
+
what the hackathon's "Improvement in Rewards" criterion rewards.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import json
|
| 34 |
+
import os
|
| 35 |
+
import sys
|
| 36 |
+
from dataclasses import dataclass
|
| 37 |
+
from pathlib import Path
|
| 38 |
+
from typing import Any, Dict, List
|
| 39 |
+
|
| 40 |
+
import matplotlib.pyplot as plt
|
| 41 |
+
|
| 42 |
+
# Ensure api_contract_validator is importable
|
| 43 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 44 |
+
sys.path.insert(0, str(ROOT))
|
| 45 |
+
|
| 46 |
+
# Load .env from api_contract_validator/ before reading os.getenv values
|
| 47 |
+
try:
|
| 48 |
+
from dotenv import load_dotenv
|
| 49 |
+
_ENV_FILE = ROOT / ".env"
|
| 50 |
+
if _ENV_FILE.exists():
|
| 51 |
+
load_dotenv(_ENV_FILE)
|
| 52 |
+
except ImportError:
|
| 53 |
+
pass
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ── Configuration ────────────────────────────────────────────────────────
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class TrainConfig:
|
| 61 |
+
base_model: str = os.getenv(
|
| 62 |
+
"BASE_MODEL", "unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit"
|
| 63 |
+
)
|
| 64 |
+
env_url: str = os.getenv("ENV_URL", "http://localhost:7860")
|
| 65 |
+
push_to_hub_id: str | None = os.getenv("PUSH_TO_HUB", None)
|
| 66 |
+
output_dir: str = os.getenv("OUTPUT_DIR", "checkpoints/grpo")
|
| 67 |
+
results_dir: str = os.getenv(
|
| 68 |
+
"RESULTS_DIR", str(ROOT / "results")
|
| 69 |
+
)
|
| 70 |
+
seed: int = int(os.getenv("SEED", "42"))
|
| 71 |
+
|
| 72 |
+
# LoRA
|
| 73 |
+
lora_r: int = int(os.getenv("LORA_R", "16"))
|
| 74 |
+
lora_alpha: int = int(os.getenv("LORA_ALPHA", "32"))
|
| 75 |
+
|
| 76 |
+
# GRPO
|
| 77 |
+
max_seq_length: int = int(os.getenv("MAX_SEQ_LEN", "2048"))
|
| 78 |
+
num_generations: int = int(os.getenv("NUM_GENERATIONS", "4"))
|
| 79 |
+
max_steps: int = int(os.getenv("MAX_STEPS", "200"))
|
| 80 |
+
learning_rate: float = float(os.getenv("LR", "5e-6"))
|
| 81 |
+
per_device_batch_size: int = int(os.getenv("BATCH_SIZE", "1"))
|
| 82 |
+
grad_accum: int = int(os.getenv("GRAD_ACCUM", "4"))
|
| 83 |
+
|
| 84 |
+
# Tasks to train on (subset speeds up onsite training)
|
| 85 |
+
train_tasks: List[str] | None = None
|
| 86 |
+
|
| 87 |
+
# WandB
|
| 88 |
+
wandb_project: str = os.getenv("WANDB_PROJECT", "openenv-contract-guardian")
|
| 89 |
+
wandb_run: str = os.getenv("WANDB_RUN", "grpo-onsite")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ── Reward function: rolls out one step against the live env ─────────────
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def make_reward_fn(env_client, task_pool: List[str]):
|
| 96 |
+
"""Return a TRL-compatible reward_fn that grades each completion via env.
|
| 97 |
+
|
| 98 |
+
For every (prompt, completion) pair, we ask the env to score it.
|
| 99 |
+
GRPO will then promote the higher-reward completion among the
|
| 100 |
+
``num_generations`` samples per prompt.
|
| 101 |
+
"""
|
| 102 |
+
from inference import _build_action, parse_llm_response # noqa: WPS433
|
| 103 |
+
import asyncio
|
| 104 |
+
|
| 105 |
+
def reward_fn(prompts, completions, **kwargs): # noqa: ARG001
|
| 106 |
+
rewards: List[float] = []
|
| 107 |
+
loop = asyncio.get_event_loop()
|
| 108 |
+
for completion in completions:
|
| 109 |
+
text = completion if isinstance(completion, str) else completion[0]["content"]
|
| 110 |
+
try:
|
| 111 |
+
action_data = parse_llm_response(text)
|
| 112 |
+
action = _build_action(action_data)
|
| 113 |
+
# one-step roll-out: reset → step → grade → reset
|
| 114 |
+
# Each prompt is associated with a fresh episode in train_dataset,
|
| 115 |
+
# so we use the env's most recent reset state as scoring context.
|
| 116 |
+
step_result = loop.run_until_complete(env_client.step(action))
|
| 117 |
+
rewards.append(float(step_result.reward or 0.0))
|
| 118 |
+
except Exception as exc: # noqa: BLE001
|
| 119 |
+
print(f"[WARN] reward_fn error: {exc}")
|
| 120 |
+
rewards.append(-0.5)
|
| 121 |
+
return rewards
|
| 122 |
+
|
| 123 |
+
return reward_fn
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ── Dataset: one prompt per env reset ────────────────────────────────────
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def build_train_dataset(env_client, tasks: List[str], episodes_per_task: int = 50):
|
| 130 |
+
"""Roll out reset() to capture initial observations as training prompts.
|
| 131 |
+
|
| 132 |
+
Each row is one episode start. During training, GRPO samples
|
| 133 |
+
``num_generations`` completions per prompt and uses the env to
|
| 134 |
+
grade them.
|
| 135 |
+
"""
|
| 136 |
+
import asyncio
|
| 137 |
+
from datasets import Dataset # type: ignore
|
| 138 |
+
|
| 139 |
+
from inference import build_user_prompt, _system_prompt_for_phase # noqa
|
| 140 |
+
|
| 141 |
+
rows: List[Dict[str, Any]] = []
|
| 142 |
+
loop = asyncio.get_event_loop()
|
| 143 |
+
|
| 144 |
+
for task in tasks:
|
| 145 |
+
for ep in range(episodes_per_task):
|
| 146 |
+
seed = ep
|
| 147 |
+
result = loop.run_until_complete(
|
| 148 |
+
env_client.reset(task_name=task, seed=seed)
|
| 149 |
+
)
|
| 150 |
+
obs = result.observation.model_dump()
|
| 151 |
+
phase = obs.get("phase", "detection")
|
| 152 |
+
system = _system_prompt_for_phase(phase, task)
|
| 153 |
+
user = build_user_prompt(obs, step=1, history=[])
|
| 154 |
+
rows.append({
|
| 155 |
+
"prompt": [
|
| 156 |
+
{"role": "system", "content": system},
|
| 157 |
+
{"role": "user", "content": user},
|
| 158 |
+
],
|
| 159 |
+
"task": task,
|
| 160 |
+
"seed": seed,
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
return Dataset.from_list(rows)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ── Main entry point ─────────────────────────────────────────────────────
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def main() -> None:
|
| 170 |
+
cfg = TrainConfig()
|
| 171 |
+
|
| 172 |
+
# ---- Imports happen inside main so the script can be inspected
|
| 173 |
+
# ---- without the heavy deps installed.
|
| 174 |
+
import asyncio
|
| 175 |
+
from openenv.core.client_types import StepResult # noqa: F401
|
| 176 |
+
|
| 177 |
+
try:
|
| 178 |
+
from unsloth import FastLanguageModel # type: ignore
|
| 179 |
+
except ImportError as exc:
|
| 180 |
+
sys.exit(
|
| 181 |
+
"unsloth not installed. Install with: pip install unsloth trl wandb. "
|
| 182 |
+
f"({exc})"
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
from trl import GRPOConfig, GRPOTrainer # type: ignore
|
| 186 |
+
import wandb # type: ignore
|
| 187 |
+
|
| 188 |
+
from client import ValidatorEnv # noqa: WPS433
|
| 189 |
+
|
| 190 |
+
if os.getenv("WANDB_API_KEY"):
|
| 191 |
+
wandb.init(
|
| 192 |
+
project=cfg.wandb_project,
|
| 193 |
+
name=cfg.wandb_run,
|
| 194 |
+
config=cfg.__dict__,
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# 1. Connect to the env
|
| 198 |
+
env = ValidatorEnv(base_url=cfg.env_url)
|
| 199 |
+
print(f"[INFO] connected to env at {cfg.env_url}")
|
| 200 |
+
|
| 201 |
+
# 2. Choose tasks
|
| 202 |
+
train_tasks = cfg.train_tasks or [
|
| 203 |
+
"find_type_mismatches",
|
| 204 |
+
"validate_nested_objects",
|
| 205 |
+
"detect_breaking_changes",
|
| 206 |
+
"validate_response_schema",
|
| 207 |
+
"trace_downstream_blast_radius",
|
| 208 |
+
"propose_backward_compat_fix",
|
| 209 |
+
]
|
| 210 |
+
|
| 211 |
+
# 3. Build dataset
|
| 212 |
+
print(f"[INFO] building dataset for tasks={train_tasks}")
|
| 213 |
+
train_dataset = build_train_dataset(env, train_tasks)
|
| 214 |
+
|
| 215 |
+
# 4. Load model + LoRA
|
| 216 |
+
print(f"[INFO] loading model: {cfg.base_model}")
|
| 217 |
+
model, tokenizer = FastLanguageModel.from_pretrained(
|
| 218 |
+
model_name=cfg.base_model,
|
| 219 |
+
max_seq_length=cfg.max_seq_length,
|
| 220 |
+
load_in_4bit=True,
|
| 221 |
+
)
|
| 222 |
+
model = FastLanguageModel.get_peft_model(
|
| 223 |
+
model,
|
| 224 |
+
r=cfg.lora_r,
|
| 225 |
+
lora_alpha=cfg.lora_alpha,
|
| 226 |
+
target_modules=[
|
| 227 |
+
"q_proj", "k_proj", "v_proj", "o_proj",
|
| 228 |
+
"gate_proj", "up_proj", "down_proj",
|
| 229 |
+
],
|
| 230 |
+
random_state=cfg.seed,
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# 5. GRPO trainer
|
| 234 |
+
grpo_cfg = GRPOConfig(
|
| 235 |
+
output_dir=cfg.output_dir,
|
| 236 |
+
learning_rate=cfg.learning_rate,
|
| 237 |
+
per_device_train_batch_size=cfg.per_device_batch_size,
|
| 238 |
+
gradient_accumulation_steps=cfg.grad_accum,
|
| 239 |
+
num_generations=cfg.num_generations,
|
| 240 |
+
max_steps=cfg.max_steps,
|
| 241 |
+
max_prompt_length=cfg.max_seq_length // 2,
|
| 242 |
+
max_completion_length=cfg.max_seq_length // 2,
|
| 243 |
+
logging_steps=1,
|
| 244 |
+
save_steps=50,
|
| 245 |
+
report_to="wandb" if os.getenv("WANDB_API_KEY") else "none",
|
| 246 |
+
bf16=True,
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
reward_fn = make_reward_fn(env, train_tasks)
|
| 250 |
+
|
| 251 |
+
trainer = GRPOTrainer(
|
| 252 |
+
model=model,
|
| 253 |
+
processing_class=tokenizer,
|
| 254 |
+
reward_funcs=[reward_fn],
|
| 255 |
+
args=grpo_cfg,
|
| 256 |
+
train_dataset=train_dataset,
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
# 6. Train
|
| 260 |
+
print("[INFO] starting GRPO training")
|
| 261 |
+
trainer.train()
|
| 262 |
+
|
| 263 |
+
# 7. Save reward curve
|
| 264 |
+
results_dir = Path(cfg.results_dir)
|
| 265 |
+
results_dir.mkdir(parents=True, exist_ok=True)
|
| 266 |
+
|
| 267 |
+
history = [
|
| 268 |
+
h for h in trainer.state.log_history if "reward" in h
|
| 269 |
+
]
|
| 270 |
+
if history:
|
| 271 |
+
steps = [h["step"] for h in history]
|
| 272 |
+
rewards = [h["reward"] for h in history]
|
| 273 |
+
plt.figure(figsize=(8, 5))
|
| 274 |
+
plt.plot(steps, rewards, label="train reward", linewidth=2)
|
| 275 |
+
plt.xlabel("Training step")
|
| 276 |
+
plt.ylabel("Mean episode reward")
|
| 277 |
+
plt.title("GRPO Training — Enterprise Contract Guardian")
|
| 278 |
+
plt.grid(alpha=0.3)
|
| 279 |
+
plt.legend()
|
| 280 |
+
plt.tight_layout()
|
| 281 |
+
out = results_dir / "reward_curve.png"
|
| 282 |
+
plt.savefig(out, dpi=150)
|
| 283 |
+
print(f"[INFO] wrote {out}")
|
| 284 |
+
|
| 285 |
+
# 8. Save trainer state to JSON for plot.py to consume later
|
| 286 |
+
state_path = results_dir / "training_state.json"
|
| 287 |
+
state_path.write_text(json.dumps(trainer.state.log_history, indent=2))
|
| 288 |
+
print(f"[INFO] wrote {state_path}")
|
| 289 |
+
|
| 290 |
+
# 9. Push checkpoint
|
| 291 |
+
if cfg.push_to_hub_id:
|
| 292 |
+
print(f"[INFO] pushing adapter to {cfg.push_to_hub_id}")
|
| 293 |
+
model.push_to_hub(cfg.push_to_hub_id, token=os.getenv("HF_TOKEN"))
|
| 294 |
+
|
| 295 |
+
asyncio.get_event_loop().run_until_complete(env.close())
|
| 296 |
+
print("[INFO] done.")
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
if __name__ == "__main__":
|
| 300 |
+
main()
|