pushpam14 commited on
Commit
1607c63
·
verified ·
1 Parent(s): de57d9d

Deploy Enterprise Contract Guardian — finale build

Browse files
.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
- # API Contract Validator — OpenEnv Environment
15
 
16
- An OpenEnv RL environment where AI agents learn to validate API request/response
17
- payloads against OpenAPI specifications. Agents identify type mismatches, missing
18
- required fields, invalid enum values, format constraint violations, breaking API
19
- changes, cross-field arithmetic errors, and authentication schema violations.
20
 
21
- ## Why This Environment?
22
 
23
- API contract violations are one of the **top causes of production incidents** in
24
- microservice architectures. Every API integration requires validating payloads
25
- against specs a tedious, error-prone task that developers perform daily. This
26
- environment trains agents to automate this critical workflow.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  **Real-world applications:**
29
- - CI/CD pipeline contract testing
30
- - API gateway request/response validation
31
- - SDK compatibility checking between API versions
32
- - OAuth2 and auth schema enforcement
33
- - Migration safety audits
34
 
35
  ## How It Works
36
 
37
- Each episode presents the agent with:
38
- 1. An **OpenAPI specification** defining expected types, required fields, and constraints
39
- 2. A **payload** containing planted violations
40
 
41
- The agent inspects both and reports violations **one per step**. The environment
42
- grades each report against ground-truth violations and provides immediate reward feedback.
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  ```
45
- reset() → Agent sees spec + payload with N hidden violations
46
- step(violation_report)Correct? +1.0 | Wrong path, right type? +0.3 | False positive? -0.3 | Duplicate? -0.1
47
- step(HINT) → Receive a location clue at -0.5 cost
48
- step(DONE) → Episode ends with completeness bonus +0.5 × (found/total)
 
 
 
 
 
 
 
 
 
 
49
  ```
50
 
51
- ## Tasks (6 difficulty levels)
 
 
 
 
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
- Partial progress signals not binary end-of-episode scoring:
 
 
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
- **Final score** = `correct_violations / total_violations` clamped to `(0.01, 0.99)`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  ## Setup
117
 
@@ -154,18 +254,66 @@ python inference.py
154
  openenv validate
155
  ```
156
 
157
- ## Baseline Scores
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
- | Task | Model | Score | Steps |
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
- *Scores are approximate and may vary with temperature/sampling.*
 
 
 
 
 
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 logic (reset/step/state)
186
- ├── spec_generator.py # Task scenarios with planted violations
187
- ├── rewards.py # Reward computation
188
- ── requirements.txt # Server dependencies
 
 
 
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
+ ![Reward Curve](results/reward_curve.png)
267
+ *Episode reward over training steps. Baseline (untrained) vs GRPO-trained agent. x-axis: training step, y-axis: episode reward (0–1).*
268
+
269
+ ![Before vs After](results/before_after.png)
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
- TASKS = [
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
- SYSTEM_PROMPT = textwrap.dedent("""\
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 (ALWAYS use this for breaking changes)
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 the user prompt from the current observation."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  violations_found = observation.get("violations_found", [])
136
- found_summary = "None yet"
137
  if violations_found:
138
- found_lines = []
139
- for v in violations_found:
140
- found_lines.append(f" - {v['field_path']}: {v['violation_type']}")
 
141
  found_summary = "\n".join(found_lines)
142
-
143
- history_block = "\n".join(history[-5:]) if history else "None"
144
 
145
  return textwrap.dedent(f"""\
146
  Step: {step}
147
- Task: {observation.get('task_name', '')}
 
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
- def parse_llm_response(text: str) -> Dict[str, str]:
175
- """Parse the LLM response into a violation dict.
 
 
 
 
 
 
176
 
177
- Handles cases where the model wraps JSON in markdown fences.
 
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
- "field_path": "DONE",
214
- "violation_type": "",
215
- "description": "",
216
- "suggested_fix": "",
217
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  return {
219
- "field_path": "DONE",
220
- "violation_type": "unknown",
221
- "description": f"Failed to parse: {cleaned[:100]}",
222
- "suggested_fix": "",
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, 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": SYSTEM_PROMPT},
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
- return {
250
- "field_path": "DONE",
251
- "violation_type": "",
252
- "description": f"LLM error: {exc}",
253
- "suggested_fix": "",
254
- }
 
 
 
 
255
 
256
 
257
  # ---------------------------------------------------------------------------
@@ -263,30 +478,40 @@ async def run_single_task(
263
  client: OpenAI,
264
  env: ValidatorEnv,
265
  task_name: str,
266
- ) -> None:
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 # default: strictly > 0 as required by evaluator
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 = result.observation.model_dump() if hasattr(result.observation, 'model_dump') else result.observation.__dict__
 
 
 
 
282
 
283
  for step in range(1, max_steps + 1):
284
  if result.done:
285
  break
286
 
287
- # If stuck on same wrong field too many times, request a HINT
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 = ValidatorAction(
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 = result.observation.model_dump() if hasattr(result.observation, 'model_dump') else result.observation.__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 = f"{action_data['field_path']}:{action_data['violation_type']}"
318
- log_step(step=step, action=action_str, reward=reward, done=done, error=error)
319
-
320
- # Track consecutive failures on same field to trigger HINT
321
- if reward < 0 and action_data["field_path"] not in ("DONE", "HINT"):
322
- if action_data["field_path"] == last_failed_path:
 
 
 
 
 
323
  consecutive_failures += 1
324
  else:
325
  consecutive_failures = 1
326
- last_failed_path = action_data["field_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
- # Compute final score
340
- if rewards:
341
- correct_count = sum(1 for r in rewards if r >= 1.0)
342
- total_violations = obs_dict.get("violations_remaining", 0) + len(
343
- obs_dict.get("violations_found", [])
344
- )
345
- score = (
346
- correct_count / total_violations if total_violations > 0 else 0.0
347
- )
348
- # Clamp to strictly (0, 1) — evaluator rejects 0.0 and 1.0 exactly
 
 
 
 
 
 
 
 
 
 
 
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 violation report submitted by the agent.
20
 
21
- The agent inspects an API spec + payload pair and reports one violation
22
- per step. Submitting ``field_path="DONE"`` signals the agent is finished.
 
 
 
 
 
 
 
 
 
 
 
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
- """What the environment returns after each agent action.
57
 
58
  Inherits ``done: bool`` and ``reward: Optional[float]`` from the
59
  ``Observation`` base class.
60
  """
61
 
62
- task_name: str = Field(
63
- default="",
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
- feedback: str = Field(
87
- default="",
88
- description="Result of the agent's last submitted report.",
 
 
 
 
 
89
  )
90
- max_steps: int = Field(
 
 
 
 
91
  default=0,
92
- description="Maximum steps allowed for the current episode.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ValidatorAction, ValidatorObservation, ValidatorState
 
 
 
 
 
 
 
 
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 ValidatorAction, ValidatorObservation, ValidatorState
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
- If ``task_name`` is passed in *kwargs* it selects a specific task;
164
- otherwise the environment cycles through all available tasks.
 
 
 
 
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
- self._scenario = generate_scenario_for_task(task_name, seed=seed)
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
- """Process one violation report from the agent."""
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
- Reward computation for the API Contract Validator Environment.
3
-
4
- Provides partial-progress reward signals rather than binary end-of-episode
5
- scoring, creating a richer gradient for RL training:
6
-
7
- - Correct violation found → +1.0 primary incentive, one reward per unique violation
8
- - Path-only match (wrong type) → +0.3 proximity signal — agent found the right field,
9
- now needs to classify it correctly; each path
10
- earns proximity reward at most once
11
- - HINT requested → -0.5 informative but expensive; use sparingly
12
- - Duplicate report → -0.1 light penalty — agent should track findings
13
- - False positive → -0.3 penalises random guessing
14
- - DONE signal → +0.5 × (found/total) completeness bonus on exit
15
-
16
- Final episode score = correct / total, clamped to (0.01, 0.99) so it is
17
- always strictly between 0 and 1 as required by the OpenEnv eval pipeline.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  """
19
 
20
- from dataclasses import dataclass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
  @dataclass
24
  class RewardBreakdown:
25
- """Detailed breakdown of a single-step reward."""
 
 
 
 
26
 
27
  reward: float
28
  is_correct: bool
@@ -34,14 +119,30 @@ class RewardBreakdown:
34
  explanation: str
35
 
36
 
37
- # ── Per-step reward values ────────────────────────────────────────────────
38
-
39
  CORRECT_VIOLATION_REWARD = 1.0
40
- PATH_MATCH_REWARD = 0.3 # right field, wrong violation_type
41
- HINT_PENALTY = -0.5 # cost of requesting a location hint
42
  DUPLICATE_PENALTY = -0.1
43
  FALSE_POSITIVE_PENALTY = -0.3
44
- DONE_BONUS_MULTIPLIER = 0.5 # bonus = multiplier * (correct / total)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 reward for a single agent step.
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"Completeness {correct_so_far}/{total_violations} "
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 violation, "
149
- "but the violation_type is wrong. Try again with the right type."
 
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
- """Compute the final normalised score for the episode.
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 riskan 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()