pushpam14 commited on
Commit
6daf142
Β·
verified Β·
1 Parent(s): e04abc7

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && \
7
+ apt-get install -y --no-install-recommends curl && \
8
+ rm -rf /var/lib/apt/lists/*
9
+
10
+ # Copy and install Python dependencies first (cache layer)
11
+ COPY server/requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy project files
15
+ COPY . .
16
+
17
+ # Set PYTHONPATH so imports resolve correctly
18
+ ENV PYTHONPATH="/app:$PYTHONPATH"
19
+
20
+ # Expose the port HF Spaces expects
21
+ EXPOSE 7860
22
+
23
+ # Health check
24
+ 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"]
README.md CHANGED
@@ -1,10 +1,178 @@
1
  ---
2
- title: Api Contract Validator
3
- emoji: 🏒
4
  colorFrom: blue
5
- colorTo: red
6
  sdk: docker
 
 
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: API Contract Validator
3
+ emoji: πŸ“‹
4
  colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ 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 payloads against OpenAPI specifications. Agents identify type mismatches, missing required fields, invalid enum values, and breaking changes between API versions.
17
+
18
+ ## Why This Environment?
19
+
20
+ API contract violations are one of the **top causes of production incidents** in microservice architectures. Every API integration requires validating payloads against specs β€” a tedious, error-prone task that developers perform daily. This environment trains agents to automate this critical workflow.
21
+
22
+ **Real-world applications:**
23
+ - CI/CD pipeline contract testing
24
+ - API gateway validation
25
+ - SDK compatibility checking
26
+ - Migration safety audits between API versions
27
+
28
+ ## How It Works
29
+
30
+ Each episode presents the agent with:
31
+ 1. An **OpenAPI specification** defining expected types, required fields, and constraints
32
+ 2. A **payload** containing planted violations
33
+
34
+ The agent inspects both and reports violations **one per step**. The environment grades each report against ground-truth violations and provides immediate feedback.
35
+
36
+ ```
37
+ reset() β†’ Agent sees spec + payload
38
+ step(violation_report) β†’ Correct? +1.0 | False positive? -0.3 | Duplicate? -0.1
39
+ step(DONE) β†’ Episode ends with completeness bonus
40
+ ```
41
+
42
+ ## Tasks (4 difficulty levels)
43
+
44
+ | Task | Difficulty | Violations | Max Steps | What the Agent Must Find |
45
+ |------|-----------|------------|-----------|--------------------------|
46
+ | `find_type_mismatches` | Easy | 4 | 10 | Type mismatches, missing required fields, invalid enums at the top level |
47
+ | `validate_nested_objects` | Medium | 7 | 15 | Violations inside nested objects and arrays β€” requires traversing deep structures |
48
+ | `detect_breaking_changes` | Hard | 9 | 20 | Breaking changes between two API spec versions β€” type changes, removed fields, narrowed enums, new requirements |
49
+ | `validate_response_schema` | Expert | 10 | 25 | Subtle format errors in an API response: invalid date formats, pattern mismatches, out-of-range numerics, and bad enum values scattered across nested objects and arrays |
50
+
51
+ ### Randomised Episode Generation
52
+
53
+ All tasks support seed-based randomisation, making the environment suitable for **training** agents, not just evaluating them:
54
+
55
+ - `find_type_mismatches` β€” samples 4 violations from a pool of 8 (70 unique combinations)
56
+ - `validate_nested_objects` β€” two complete scenario variants (Order Service / Event Booking)
57
+ - `validate_response_schema` β€” two complete scenario variants with different violation sets
58
+ - Pass `seed` in the `reset()` call to select a deterministic episode
59
+
60
+ ## Action Space
61
+
62
+ Each step the agent submits a `ValidatorAction`:
63
+
64
+ | Field | Type | Description |
65
+ |-------|------|-------------|
66
+ | `field_path` | `str` | Dot-notation path to the violated field (e.g., `customer.email`, `items[1].quantity`). Use `DONE` to end. |
67
+ | `violation_type` | `str` | One of: `type_mismatch`, `missing_required`, `invalid_enum`, `format_error`, `extra_field`, `breaking_change` |
68
+ | `description` | `str` | Human-readable explanation of the violation |
69
+ | `suggested_fix` | `str` | Optional suggested correction |
70
+
71
+ ## Observation Space
72
+
73
+ After each step the agent receives a `ValidatorObservation`:
74
+
75
+ | Field | Type | Description |
76
+ |-------|------|-------------|
77
+ | `task_name` | `str` | Current task identifier |
78
+ | `task_description` | `str` | Natural-language instructions |
79
+ | `api_spec` | `dict` | The OpenAPI specification (or version diff for hard task) |
80
+ | `payload` | `dict` | The payload to validate |
81
+ | `violations_found` | `list[dict]` | Violations correctly identified so far |
82
+ | `violations_remaining` | `int` | How many planted violations are still undetected |
83
+ | `feedback` | `str` | Result of the last submitted report |
84
+ | `max_steps` | `int` | Step budget for the episode |
85
+ | `done` | `bool` | Whether the episode has ended |
86
+ | `reward` | `float` | Reward for the last action |
87
+
88
+ ## Reward Function
89
+
90
+ The reward function provides **partial progress signals** β€” not binary end-of-episode scoring:
91
+
92
+ | Event | Reward | Rationale |
93
+ |-------|--------|-----------|
94
+ | Correct violation found | +1.0 | Primary incentive β€” each discovery is rewarded |
95
+ | False positive | -0.3 | Penalises guessing without being too harsh |
96
+ | Duplicate report | -0.1 | Light penalty β€” agent should track what it already reported |
97
+ | DONE signal | +0.5 Γ— (found/total) | Bonus proportional to completeness |
98
+
99
+ **Final score** = `correct_violations / total_violations` ∈ [0.0, 1.0]
100
+
101
+ ## Setup
102
+
103
+ ### Prerequisites
104
+
105
+ - Python 3.10+
106
+ - Docker (for containerised deployment)
107
+ - `openenv-core` (`pip install openenv-core`)
108
+
109
+ ### Local Development
110
+
111
+ ```bash
112
+ git clone <your-repo-url>
113
+ cd api_contract_validator
114
+ pip install -e .
115
+
116
+ uvicorn server.app:app --host 0.0.0.0 --port 7860 --reload
117
+ ```
118
+
119
+ ### Docker
120
+
121
+ ```bash
122
+ docker build -t api-contract-validator .
123
+ docker run -p 7860:7860 api-contract-validator
124
+ ```
125
+
126
+ ### Run Inference
127
+
128
+ ```bash
129
+ export API_BASE_URL="https://router.huggingface.co/v1"
130
+ export MODEL_NAME="Qwen/Qwen2.5-72B-Instruct"
131
+ export HF_TOKEN="your-token-here"
132
+
133
+ python inference.py
134
+ ```
135
+
136
+ ## Validate Submission
137
+
138
+ ```bash
139
+ openenv validate
140
+
141
+ # Full pre-submission check
142
+ ./validate-submission.sh https://your-space.hf.space
143
+ ```
144
+
145
+ ## Baseline Scores
146
+
147
+ | Task | Model | Score | Steps |
148
+ |------|-------|-------|-------|
149
+ | `find_type_mismatches` | Qwen2.5-72B-Instruct | ~0.75 | 5–7 |
150
+ | `validate_nested_objects` | Qwen2.5-72B-Instruct | ~0.57 | 8–12 |
151
+ | `detect_breaking_changes` | Qwen2.5-72B-Instruct | ~0.44 | 12–18 |
152
+
153
+ *Scores are approximate and may vary with temperature/sampling.*
154
+
155
+ ## Project Structure
156
+
157
+ ```
158
+ api_contract_validator/
159
+ β”œβ”€β”€ openenv.yaml # OpenEnv manifest
160
+ β”œβ”€β”€ pyproject.toml # Python project metadata
161
+ β”œβ”€β”€ Dockerfile # Container definition
162
+ β”œβ”€β”€ inference.py # Baseline inference script
163
+ β”œβ”€β”€ README.md # This file
164
+ β”œβ”€β”€ models.py # Pydantic models (Action, Observation, State)
165
+ β”œβ”€β”€ client.py # WebSocket client (EnvClient subclass)
166
+ β”œβ”€β”€ __init__.py # Package exports
167
+ └── server/
168
+ β”œβ”€β”€ __init__.py
169
+ β”œβ”€β”€ app.py # FastAPI wiring (create_app)
170
+ β”œβ”€β”€ environment.py # Core environment logic (reset/step/state)
171
+ β”œβ”€β”€ spec_generator.py # Task scenarios with planted violations
172
+ β”œβ”€β”€ rewards.py # Reward computation
173
+ └── requirements.txt # Server dependencies
174
+ ```
175
+
176
+ ## License
177
+
178
+ BSD-style β€” see LICENSE file.
__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API Contract Validator Environment."""
2
+
3
+ try:
4
+ from .client import ValidatorEnv
5
+ from .models import ValidatorAction, ValidatorObservation, ValidatorState
6
+ except (ImportError, ModuleNotFoundError):
7
+ from client import ValidatorEnv
8
+ from models import ValidatorAction, ValidatorObservation, ValidatorState
9
+
10
+ __all__ = [
11
+ "ValidatorAction",
12
+ "ValidatorObservation",
13
+ "ValidatorState",
14
+ "ValidatorEnv",
15
+ ]
client.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API Contract Validator Environment Client.
3
+
4
+ Maintains a persistent WebSocket connection to the environment server.
5
+ Each client instance has its own isolated session.
6
+ """
7
+
8
+ from typing import Any, Dict
9
+
10
+ from openenv.core import EnvClient
11
+ from openenv.core.client_types import StepResult
12
+
13
+ try:
14
+ from .models import ValidatorAction, ValidatorObservation, ValidatorState
15
+ except (ImportError, ModuleNotFoundError):
16
+ from models import ValidatorAction, ValidatorObservation, ValidatorState
17
+
18
+
19
+ class ValidatorEnv(
20
+ EnvClient[ValidatorAction, ValidatorObservation, ValidatorState]
21
+ ):
22
+ """Client for the API Contract Validator Environment.
23
+
24
+ Example::
25
+
26
+ with ValidatorEnv(base_url="http://localhost:7860").sync() as env:
27
+ result = env.reset()
28
+ print(result.observation.task_name)
29
+ result = env.step(ValidatorAction(
30
+ field_path="email",
31
+ violation_type="missing_required",
32
+ description="Required field 'email' is missing.",
33
+ ))
34
+ print(result.observation.feedback)
35
+
36
+ Example with Docker::
37
+
38
+ env = ValidatorEnv.from_docker_image("api-contract-validator:latest")
39
+ result = env.reset()
40
+ ...
41
+ env.close()
42
+ """
43
+
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(
54
+ self, payload: Dict[str, Any]
55
+ ) -> StepResult[ValidatorObservation]:
56
+ """Parse the server response into a typed StepResult."""
57
+ obs_data = payload.get("observation", {})
58
+ observation = ValidatorObservation(
59
+ done=payload.get("done", False),
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
+ )
70
+ return StepResult(
71
+ observation=observation,
72
+ reward=payload.get("reward"),
73
+ done=payload.get("done", False),
74
+ )
75
+
76
+ def _parse_state(self, payload: Dict[str, Any]) -> ValidatorState:
77
+ """Parse the state response into a typed ValidatorState."""
78
+ return ValidatorState(
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
+ )
inference.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Baseline Inference Script for API Contract Validator Environment.
3
+
4
+ Runs an LLM agent against all tasks and produces scores.
5
+ Uses the OpenAI client for all LLM calls.
6
+
7
+ Environment variables:
8
+ API_BASE_URL β€” API endpoint (default: HF router)
9
+ MODEL_NAME β€” Model identifier (default: Qwen2.5-72B-Instruct)
10
+ HF_TOKEN β€” Hugging Face / API key (no default)
11
+ LOCAL_IMAGE_NAME β€” Docker image name when using from_docker_image()
12
+
13
+ STDOUT format follows the hackathon specification exactly:
14
+ [START] task=<task_name> env=<benchmark> model=<model_name>
15
+ [STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
16
+ [END] success=<true|false> steps=<n> rewards=<r1,r2,...,rn>
17
+ """
18
+
19
+ 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
28
+ from models import ValidatorAction
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Configuration
32
+ # ---------------------------------------------------------------------------
33
+
34
+ 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",
44
+ "validate_response_schema",
45
+ "validate_cross_field_constraints",
46
+ ]
47
+ MAX_STEPS_PER_TASK = {
48
+ "find_type_mismatches": 10,
49
+ "validate_nested_objects": 15,
50
+ "detect_breaking_changes": 20,
51
+ "validate_response_schema": 25,
52
+ "validate_cross_field_constraints": 18,
53
+ }
54
+ TEMPERATURE = 0.2
55
+ MAX_TOKENS = 1024
56
+ SUCCESS_SCORE_THRESHOLD = 0.3
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Structured stdout logging
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def log_start(task: str, env: str, model: str) -> None:
65
+ print(f"[START] task={task} env={env} model={model}", flush=True)
66
+
67
+
68
+ def log_step(
69
+ step: int, action: str, reward: float, done: bool, error: Optional[str]
70
+ ) -> None:
71
+ error_val = error if error else "null"
72
+ done_val = str(done).lower()
73
+ print(
74
+ f"[STEP] step={step} action={action} reward={reward:.2f} "
75
+ f"done={done_val} error={error_val}",
76
+ flush=True,
77
+ )
78
+
79
+
80
+ def log_end(
81
+ success: bool, steps: int, score: float, rewards: List[float]
82
+ ) -> None:
83
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
84
+ print(
85
+ f"[END] success={str(success).lower()} steps={steps} rewards={rewards_str}",
86
+ flush=True,
87
+ )
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Prompt construction
92
+ # ---------------------------------------------------------------------------
93
+
94
+ SYSTEM_PROMPT = textwrap.dedent("""\
95
+ You are an expert API contract validator. You will be given an OpenAPI \
96
+ specification and an API payload. Your job is to find violations in the \
97
+ payload that do not conform to the spec.
98
+
99
+ Each turn you must respond with EXACTLY one JSON object (no markdown, no \
100
+ explanation outside the JSON) with these fields:
101
+ {
102
+ "field_path": "<dot-notation path to the violated field, or 'DONE' if no more violations>",
103
+ "violation_type": "<type_mismatch|missing_required|invalid_enum|format_error|extra_field|breaking_change|cross_field_constraint>",
104
+ "description": "<brief explanation>",
105
+ "suggested_fix": "<how to fix it>"
106
+ }
107
+
108
+ Rules:
109
+ - Report ONE violation per turn.
110
+ - Use dot-notation for nested paths: 'customer.email'
111
+ - Use bracket notation for arrays: 'items[1].quantity'
112
+ - For breaking changes use path format: 'METHOD /path.field' e.g. 'POST /products.price'
113
+ - For breaking changes between API versions, ALWAYS use violation_type='breaking_change'.
114
+ - For cross-field constraints (arithmetic, date ordering, conditional requirements), use violation_type='cross_field_constraint'.
115
+ - The field_path must contain ONLY the path β€” never include the violation_type inside the field_path.
116
+ - When you have found all violations, respond with field_path set to 'DONE'.
117
+ - Do NOT repeat a violation you already reported.
118
+ - You may submit field_path='HINT' to receive a location hint at a cost of -0.5 reward.
119
+ """)
120
+
121
+
122
+ def build_user_prompt(
123
+ observation: Dict[str, Any],
124
+ step: int,
125
+ history: List[str],
126
+ ) -> str:
127
+ """Build the user prompt from the current observation."""
128
+ violations_found = observation.get("violations_found", [])
129
+ found_summary = "None yet"
130
+ if violations_found:
131
+ found_lines = []
132
+ for v in violations_found:
133
+ found_lines.append(f" - {v['field_path']}: {v['violation_type']}")
134
+ found_summary = "\n".join(found_lines)
135
+
136
+ history_block = "\n".join(history[-5:]) if history else "None"
137
+
138
+ return textwrap.dedent(f"""\
139
+ Step: {step}
140
+ Task: {observation.get('task_name', '')}
141
+ Instructions: {observation.get('task_description', '')}
142
+
143
+ API Specification:
144
+ {json.dumps(observation.get('api_spec', {}), indent=2)}
145
+
146
+ Payload to validate:
147
+ {json.dumps(observation.get('payload', {}), indent=2)}
148
+
149
+ Violations found so far:
150
+ {found_summary}
151
+
152
+ Violations remaining: {observation.get('violations_remaining', '?')}
153
+ Last feedback: {observation.get('feedback', '')}
154
+
155
+ Previous steps:
156
+ {history_block}
157
+
158
+ Respond with a single JSON object for the next violation (or DONE).
159
+ """)
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # LLM interaction
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ def parse_llm_response(text: str) -> Dict[str, str]:
168
+ """Parse the LLM response into a violation dict.
169
+
170
+ Handles cases where the model wraps JSON in markdown fences.
171
+ """
172
+ cleaned = text.strip()
173
+ if cleaned.startswith("```"):
174
+ lines = cleaned.split("\n")
175
+ lines = [l for l in lines if not l.strip().startswith("```")]
176
+ cleaned = "\n".join(lines).strip()
177
+
178
+ _VALID_TYPES = {
179
+ "type_mismatch", "missing_required", "invalid_enum",
180
+ "format_error", "extra_field", "breaking_change", "cross_field_constraint",
181
+ }
182
+
183
+ try:
184
+ data = json.loads(cleaned)
185
+ field_path = str(data.get("field_path", "DONE"))
186
+ violation_type = str(data.get("violation_type", "unknown"))
187
+
188
+ # LLMs sometimes embed ":violation_type" inside field_path β€” strip it
189
+ if ":" in field_path:
190
+ parts = field_path.split(":")
191
+ # Only strip if the suffix looks like a violation type keyword
192
+ if any(vt in parts[-1].lower() for vt in _VALID_TYPES):
193
+ field_path = parts[0].strip()
194
+
195
+ return {
196
+ "field_path": field_path,
197
+ "violation_type": violation_type,
198
+ "description": str(data.get("description", "")),
199
+ "suggested_fix": str(data.get("suggested_fix", "")),
200
+ }
201
+ except json.JSONDecodeError:
202
+ # If the model just says "DONE" or similar
203
+ upper = cleaned.upper()
204
+ if "DONE" in upper:
205
+ return {
206
+ "field_path": "DONE",
207
+ "violation_type": "",
208
+ "description": "",
209
+ "suggested_fix": "",
210
+ }
211
+ return {
212
+ "field_path": "DONE",
213
+ "violation_type": "unknown",
214
+ "description": f"Failed to parse: {cleaned[:100]}",
215
+ "suggested_fix": "",
216
+ }
217
+
218
+
219
+ def query_llm(
220
+ client: OpenAI,
221
+ observation: Dict[str, Any],
222
+ step: int,
223
+ history: List[str],
224
+ ) -> Dict[str, str]:
225
+ """Send the current observation to the LLM and return a parsed action."""
226
+ user_prompt = build_user_prompt(observation, step, history)
227
+ try:
228
+ completion = client.chat.completions.create(
229
+ model=MODEL_NAME,
230
+ messages=[
231
+ {"role": "system", "content": SYSTEM_PROMPT},
232
+ {"role": "user", "content": user_prompt},
233
+ ],
234
+ temperature=TEMPERATURE,
235
+ max_tokens=MAX_TOKENS,
236
+ stream=False,
237
+ )
238
+ raw_text = (completion.choices[0].message.content or "").strip()
239
+ return parse_llm_response(raw_text)
240
+ except Exception as exc:
241
+ print(f"[DEBUG] LLM request failed: {exc}", flush=True)
242
+ return {
243
+ "field_path": "DONE",
244
+ "violation_type": "",
245
+ "description": f"LLM error: {exc}",
246
+ "suggested_fix": "",
247
+ }
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Main loop
252
+ # ---------------------------------------------------------------------------
253
+
254
+
255
+ async def run_single_task(
256
+ client: OpenAI,
257
+ env: ValidatorEnv,
258
+ task_name: str,
259
+ ) -> None:
260
+ """Run a single task episode and emit structured logs."""
261
+ max_steps = MAX_STEPS_PER_TASK.get(task_name, 15)
262
+ history: List[str] = []
263
+ rewards: List[float] = []
264
+ steps_taken = 0
265
+ score = 0.0
266
+ success = False
267
+
268
+ log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
269
+
270
+ try:
271
+ result = await env.reset(task_name=task_name)
272
+ obs_dict = result.observation.model_dump() if hasattr(result.observation, 'model_dump') else result.observation.__dict__
273
+
274
+ for step in range(1, max_steps + 1):
275
+ if result.done:
276
+ break
277
+
278
+ action_data = query_llm(client, obs_dict, step, history)
279
+
280
+ action = ValidatorAction(
281
+ field_path=action_data["field_path"],
282
+ violation_type=action_data["violation_type"],
283
+ description=action_data["description"],
284
+ suggested_fix=action_data["suggested_fix"],
285
+ )
286
+
287
+ result = await env.step(action)
288
+ obs_dict = result.observation.model_dump() if hasattr(result.observation, 'model_dump') else result.observation.__dict__
289
+
290
+ reward = result.reward or 0.0
291
+ done = result.done
292
+ error = None
293
+
294
+ rewards.append(reward)
295
+ steps_taken = step
296
+
297
+ action_str = f"{action_data['field_path']}:{action_data['violation_type']}"
298
+ log_step(step=step, action=action_str, reward=reward, done=done, error=error)
299
+
300
+ history.append(
301
+ f"Step {step}: {action_str} β†’ reward {reward:+.2f}"
302
+ )
303
+
304
+ if done:
305
+ break
306
+
307
+ # Compute final score
308
+ if rewards:
309
+ correct_count = sum(1 for r in rewards if r >= 1.0)
310
+ total_violations = obs_dict.get("violations_remaining", 0) + len(
311
+ obs_dict.get("violations_found", [])
312
+ )
313
+ score = (
314
+ correct_count / total_violations if total_violations > 0 else 0.0
315
+ )
316
+ score = min(max(score, 0.0), 1.0)
317
+ success = score >= SUCCESS_SCORE_THRESHOLD
318
+
319
+ finally:
320
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
321
+
322
+
323
+ async def main() -> None:
324
+ """Run the inference agent against all tasks."""
325
+ openai_client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
326
+
327
+ if LOCAL_IMAGE_NAME:
328
+ env = await ValidatorEnv.from_docker_image(LOCAL_IMAGE_NAME)
329
+ else:
330
+ env_url = os.getenv("ENV_BASE_URL", "http://localhost:7860")
331
+ env = ValidatorEnv(base_url=env_url)
332
+
333
+ try:
334
+ for task_name in TASKS:
335
+ await run_single_task(openai_client, env, task_name)
336
+ finally:
337
+ try:
338
+ await env.close()
339
+ except Exception as exc:
340
+ print(f"[DEBUG] env.close() error: {exc}", flush=True)
341
+
342
+
343
+ if __name__ == "__main__":
344
+ asyncio.run(main())
models.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
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
9
+
10
+ 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. "
30
+ "Use 'HINT' to receive a location hint at -0.5 reward cost."
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 | "
38
+ "cross_field_constraint"
39
+ ),
40
+ )
41
+ description: str = Field(
42
+ default="",
43
+ description="Human-readable explanation of the violation.",
44
+ )
45
+ suggested_fix: str = Field(
46
+ default="",
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).",
73
+ )
74
+ payload: Dict[str, Any] = Field(
75
+ default_factory=dict,
76
+ description="The API request/response payload to validate.",
77
+ )
78
+ violations_found: List[Dict[str, str]] = Field(
79
+ default_factory=list,
80
+ description="Violations the agent has correctly identified so far.",
81
+ )
82
+ violations_remaining: int = Field(
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
+
96
+ # ---------------------------------------------------------------------------
97
+ # State β€” internal environment state (includes ground-truth)
98
+ # ---------------------------------------------------------------------------
99
+
100
+ class ValidatorState(State):
101
+ """Full environment state including ground-truth violations.
102
+
103
+ Inherits ``episode_id: Optional[str]`` and ``step_count: int`` from
104
+ the ``State`` base class.
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.0
openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: api_contract_validator
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 7860
pyproject.toml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openenv-api-contract-validator"
7
+ version = "0.1.0"
8
+ description = "API Contract Validator β€” an OpenEnv RL environment for validating API payloads against OpenAPI specifications."
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "openenv-core[core]>=0.2.2",
12
+ "openai>=1.0.0",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=8.0.0",
18
+ "pytest-cov>=4.0.0",
19
+ "httpx>=0.25.0",
20
+ ]
21
+
22
+ [project.scripts]
23
+ server = "api_contract_validator.server.app:main"
24
+
25
+ [tool.setuptools]
26
+ include-package-data = true
27
+ packages = ["api_contract_validator", "api_contract_validator.server"]
28
+ [tool.setuptools.package-dir]
29
+ "api_contract_validator" = "."
30
+ "api_contract_validator.server" = "server"
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """API Contract Validator β€” server package."""
server/app.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application for the API Contract Validator Environment.
3
+
4
+ Exposes the ValidatorEnvironment over HTTP and WebSocket endpoints
5
+ using ``openenv.core.env_server.http_server.create_app``.
6
+
7
+ Endpoints created automatically:
8
+ - POST /reset β€” Reset the environment
9
+ - POST /step β€” Execute an action
10
+ - GET /state β€” Get current environment state
11
+ - GET /health β€” Health check
12
+ - WS /ws β€” WebSocket for persistent sessions
13
+ - GET /docs β€” Swagger UI
14
+ """
15
+
16
+ try:
17
+ from openenv.core.env_server.http_server import create_app
18
+ except Exception as exc:
19
+ raise ImportError(
20
+ "openenv is required. Install with: pip install openenv-core"
21
+ ) from exc
22
+
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,
36
+ ValidatorAction,
37
+ ValidatorObservation,
38
+ env_name="api_contract_validator",
39
+ max_concurrent_envs=10,
40
+ )
41
+
42
+
43
+ def main(host: str = "0.0.0.0", port: int = 7860) -> None:
44
+ """Entry point for ``uv run server`` or direct execution."""
45
+ import uvicorn
46
+
47
+ uvicorn.run(app, host=host, port=port)
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
server/environment.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API Contract Validator Environment Implementation.
3
+
4
+ The agent validates API payloads against OpenAPI specifications by
5
+ reporting violations one at a time. The environment grades each
6
+ report against planted ground-truth violations and provides partial
7
+ reward signals.
8
+
9
+ Special field_path values:
10
+ 'DONE' β€” end the episode and collect the completeness bonus
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,
35
+ PlantedViolation,
36
+ TaskScenario,
37
+ generate_scenario_for_task,
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(" ", "")
44
+
45
+
46
+ def _find_matching_violation(
47
+ reported_path: str,
48
+ reported_type: str,
49
+ ground_truth: List[PlantedViolation],
50
+ ) -> Optional[PlantedViolation]:
51
+ """Return the first ground-truth violation that matches both path and type.
52
+
53
+ Matching is intentionally lenient: paths are compared after normalisation
54
+ and violation_type uses substring matching.
55
+ """
56
+ norm_path = _normalise_path(reported_path)
57
+ norm_type = reported_type.strip().lower()
58
+
59
+ for violation in ground_truth:
60
+ gt_path = _normalise_path(violation.field_path)
61
+ gt_type = violation.violation_type.strip().lower()
62
+
63
+ path_match = (norm_path == gt_path) or (
64
+ norm_path in gt_path or gt_path in norm_path
65
+ )
66
+ type_match = (norm_type == gt_type) or (
67
+ norm_type in gt_type or gt_type in norm_type
68
+ )
69
+
70
+ if path_match and type_match:
71
+ return violation
72
+ return None
73
+
74
+
75
+ def _find_path_only_match(
76
+ reported_path: str,
77
+ ground_truth: List[PlantedViolation],
78
+ already_matched: Set[str],
79
+ already_proximity: Set[str],
80
+ ) -> Optional[PlantedViolation]:
81
+ """Return a violation whose path matches but has not yet been fully matched.
82
+
83
+ Used for the proximity reward: agent found the right field but wrong type.
84
+ Ignores violations that have already been correctly reported OR already
85
+ received a proximity reward (to prevent reward farming).
86
+ """
87
+ norm_path = _normalise_path(reported_path)
88
+
89
+ for violation in ground_truth:
90
+ gt_path = _normalise_path(violation.field_path)
91
+ if gt_path in already_matched or gt_path in already_proximity:
92
+ continue
93
+ path_match = (norm_path == gt_path) or (
94
+ norm_path in gt_path or gt_path in norm_path
95
+ )
96
+ if path_match:
97
+ return violation
98
+ return None
99
+
100
+
101
+ def _hint_section(field_path: str) -> str:
102
+ """Extract the top-level section name from a field path.
103
+
104
+ Examples:
105
+ 'customer.email' β†’ 'customer'
106
+ 'items[1].quantity' β†’ 'items'
107
+ 'billing.tax_rate' β†’ 'billing'
108
+ 'due_date' β†’ 'due_date'
109
+ 'POST /products.price' β†’ 'POST /products'
110
+ """
111
+ path = field_path.strip()
112
+ # Handle breaking-change paths like "POST /products.price"
113
+ if path.startswith(("GET ", "POST ", "PUT ", "PATCH ", "DELETE ")):
114
+ dot_idx = path.find(".")
115
+ return path[:dot_idx] if dot_idx != -1 else path
116
+ # Standard paths: split on first dot or bracket
117
+ for i, ch in enumerate(path):
118
+ if ch in (".", "["):
119
+ return path[:i]
120
+ return path
121
+
122
+
123
+ class ValidatorEnvironment(Environment):
124
+ """API Contract Validator β€” an OpenEnv RL environment.
125
+
126
+ At the start of each episode the environment loads a task scenario
127
+ containing an API spec, a payload, and a set of planted violations.
128
+ The agent inspects the spec + payload and reports violations one per
129
+ step. The episode ends when the agent sends ``DONE`` or exhausts its
130
+ step budget.
131
+
132
+ Special actions:
133
+ field_path='DONE' β€” end episode, collect completeness bonus
134
+ field_path='HINT' β€” receive a location hint, pay -0.5 reward
135
+
136
+ Attributes
137
+ ----------
138
+ SUPPORTS_CONCURRENT_SESSIONS : bool
139
+ True β€” each WebSocket connection gets its own isolated instance.
140
+ """
141
+
142
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
143
+
144
+ def __init__(self) -> None:
145
+ super().__init__()
146
+ self._state = ValidatorState()
147
+ self._scenario: Optional[TaskScenario] = None
148
+ self._matched_paths: Set[str] = set()
149
+ self._proximity_paths: Set[str] = set()
150
+ self._reported_violations: List[Dict[str, str]] = []
151
+ self._task_index: int = 0
152
+
153
+ # ── reset ─────────────────────────────────────────────────────────
154
+
155
+ def reset(
156
+ self,
157
+ seed: Optional[int] = None,
158
+ episode_id: Optional[str] = None,
159
+ **kwargs: Any,
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,
183
+ duplicate_reports=0,
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=[],
195
+ violations_remaining=len(self._scenario.violations),
196
+ feedback="Episode started. Inspect the spec and payload, then report violations.",
197
+ max_steps=self._scenario.max_steps,
198
+ )
199
+
200
+ # ── step ──────────────────────────────────────────────────────────
201
+
202
+ def step(
203
+ self,
204
+ action: ValidatorAction,
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
+
215
+ # ── HINT request ──────────────────────────────────────────────
216
+ if signal == "HINT":
217
+ remaining = [
218
+ v for v in self._scenario.violations
219
+ if _normalise_path(v.field_path) not in self._matched_paths
220
+ ]
221
+ if remaining:
222
+ section = _hint_section(remaining[0].field_path)
223
+ hint_msg = (
224
+ f"Hint: An undetected violation is in the '{section}' section. "
225
+ f"(-0.5 reward)"
226
+ )
227
+ else:
228
+ hint_msg = "All violations have already been found. Submit DONE."
229
+
230
+ breakdown = compute_step_reward(
231
+ is_correct=False,
232
+ is_path_match=False,
233
+ is_duplicate=False,
234
+ is_done_signal=False,
235
+ is_hint=True,
236
+ correct_so_far=self._state.correct_reports,
237
+ total_violations=self._state.total_violations,
238
+ )
239
+ return self._build_observation(
240
+ reward=breakdown.reward,
241
+ done=False,
242
+ feedback=hint_msg,
243
+ )
244
+
245
+ # ── DONE signal ───────────────────────────────────────────────
246
+ if signal == "DONE":
247
+ breakdown = compute_step_reward(
248
+ is_correct=False,
249
+ is_path_match=False,
250
+ is_duplicate=False,
251
+ is_done_signal=True,
252
+ correct_so_far=self._state.correct_reports,
253
+ total_violations=self._state.total_violations,
254
+ )
255
+ self._state.score = compute_episode_score(
256
+ self._state.correct_reports,
257
+ self._state.total_violations,
258
+ )
259
+ return self._build_observation(
260
+ reward=breakdown.reward,
261
+ done=True,
262
+ feedback=breakdown.explanation,
263
+ )
264
+
265
+ # ── Duplicate check ───────────────────────────────────────────
266
+ norm_reported = _normalise_path(action.field_path)
267
+ if norm_reported in self._matched_paths:
268
+ breakdown = compute_step_reward(
269
+ is_correct=False,
270
+ is_path_match=False,
271
+ is_duplicate=True,
272
+ is_done_signal=False,
273
+ correct_so_far=self._state.correct_reports,
274
+ total_violations=self._state.total_violations,
275
+ )
276
+ self._state.duplicate_reports += 1
277
+ return self._build_observation(
278
+ reward=breakdown.reward,
279
+ done=False,
280
+ feedback=breakdown.explanation,
281
+ )
282
+
283
+ # ── Full match (path + type) ──────────────────────────────────
284
+ matched = _find_matching_violation(
285
+ action.field_path,
286
+ action.violation_type,
287
+ self._scenario.violations,
288
+ )
289
+
290
+ if matched is not None:
291
+ gt_path = _normalise_path(matched.field_path)
292
+ self._matched_paths.add(gt_path)
293
+ self._proximity_paths.discard(gt_path)
294
+ self._state.correct_reports += 1
295
+ self._reported_violations.append(
296
+ {
297
+ "field_path": matched.field_path,
298
+ "violation_type": matched.violation_type,
299
+ "description": matched.description,
300
+ }
301
+ )
302
+ breakdown = compute_step_reward(
303
+ is_correct=True,
304
+ is_path_match=False,
305
+ is_duplicate=False,
306
+ is_done_signal=False,
307
+ correct_so_far=self._state.correct_reports,
308
+ total_violations=self._state.total_violations,
309
+ )
310
+
311
+ all_found = self._state.correct_reports >= self._state.total_violations
312
+ steps_exhausted = self._state.step_count >= self._scenario.max_steps
313
+ done = all_found or steps_exhausted
314
+
315
+ if done:
316
+ self._state.score = compute_episode_score(
317
+ self._state.correct_reports,
318
+ self._state.total_violations,
319
+ )
320
+
321
+ feedback = breakdown.explanation
322
+ if all_found:
323
+ feedback += " All violations found β€” episode complete!"
324
+ elif steps_exhausted:
325
+ remaining = self._state.total_violations - self._state.correct_reports
326
+ feedback += f" Step limit reached. {remaining} violation(s) missed."
327
+
328
+ return self._build_observation(
329
+ reward=breakdown.reward,
330
+ done=done,
331
+ feedback=feedback,
332
+ )
333
+
334
+ # ── Proximity match (right path, wrong type) ──────────────────
335
+ path_match = _find_path_only_match(
336
+ action.field_path,
337
+ self._scenario.violations,
338
+ self._matched_paths,
339
+ self._proximity_paths,
340
+ )
341
+
342
+ if path_match is not None:
343
+ self._proximity_paths.add(_normalise_path(path_match.field_path))
344
+ breakdown = compute_step_reward(
345
+ is_correct=False,
346
+ is_path_match=True,
347
+ is_duplicate=False,
348
+ is_done_signal=False,
349
+ correct_so_far=self._state.correct_reports,
350
+ total_violations=self._state.total_violations,
351
+ )
352
+ steps_exhausted = self._state.step_count >= self._scenario.max_steps
353
+ if steps_exhausted:
354
+ self._state.score = compute_episode_score(
355
+ self._state.correct_reports,
356
+ self._state.total_violations,
357
+ )
358
+ return self._build_observation(
359
+ reward=breakdown.reward,
360
+ done=steps_exhausted,
361
+ feedback=breakdown.explanation,
362
+ )
363
+
364
+ # ── False positive ────────────────────────────────────────────
365
+ self._state.false_positives += 1
366
+ breakdown = compute_step_reward(
367
+ is_correct=False,
368
+ is_path_match=False,
369
+ is_duplicate=False,
370
+ is_done_signal=False,
371
+ correct_so_far=self._state.correct_reports,
372
+ total_violations=self._state.total_violations,
373
+ )
374
+
375
+ steps_exhausted = self._state.step_count >= self._scenario.max_steps
376
+ done = steps_exhausted
377
+
378
+ if done:
379
+ self._state.score = compute_episode_score(
380
+ self._state.correct_reports,
381
+ self._state.total_violations,
382
+ )
383
+
384
+ feedback = breakdown.explanation
385
+ if steps_exhausted:
386
+ remaining = self._state.total_violations - self._state.correct_reports
387
+ feedback += f" Step limit reached. {remaining} violation(s) missed."
388
+
389
+ return self._build_observation(
390
+ reward=breakdown.reward,
391
+ done=done,
392
+ feedback=feedback,
393
+ )
394
+
395
+ # ── state ─────────────────────────────────────────────────────────
396
+
397
+ @property
398
+ def state(self) -> ValidatorState:
399
+ """Return current internal state (includes ground-truth counts)."""
400
+ return self._state
401
+
402
+ # ── helpers ────────────────────────────────────────────────────────
403
+
404
+ def _build_observation(
405
+ self,
406
+ *,
407
+ reward: float,
408
+ done: bool,
409
+ feedback: str,
410
+ ) -> ValidatorObservation:
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),
422
+ violations_remaining=max(remaining, 0),
423
+ feedback=feedback,
424
+ max_steps=self._scenario.max_steps,
425
+ )
server/requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ openenv-core[core]>=0.2.2
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+ openai>=1.0.0
server/rewards.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. The reward function has several interesting properties:
6
+
7
+ - Correct violation found β†’ +1.0 (primary incentive)
8
+ - Path-only match (wrong type) β†’ +0.3 (proximity signal β€” learn location first)
9
+ - HINT requested β†’ -0.5 (expensive but informative)
10
+ - Duplicate report β†’ -0.1 (light penalty, track what you've found)
11
+ - False positive β†’ -0.3 (penalise guessing)
12
+ - DONE signal β†’ +0.5 Γ— (found/total) (completeness bonus)
13
+
14
+ The proximity reward creates a richer gradient: agents learn to locate the
15
+ right field first, then refine their violation classification.
16
+ """
17
+
18
+ from dataclasses import dataclass
19
+
20
+
21
+ @dataclass
22
+ class RewardBreakdown:
23
+ """Detailed breakdown of a single-step reward."""
24
+
25
+ reward: float
26
+ is_correct: bool
27
+ is_path_match: bool
28
+ is_duplicate: bool
29
+ is_false_positive: bool
30
+ is_done_signal: bool
31
+ is_hint: bool
32
+ explanation: str
33
+
34
+
35
+ # ── Per-step reward values ────────────────────────────────────────────────
36
+
37
+ CORRECT_VIOLATION_REWARD = 1.0
38
+ PATH_MATCH_REWARD = 0.3 # right field, wrong violation_type
39
+ HINT_PENALTY = -0.5 # cost of requesting a location hint
40
+ DUPLICATE_PENALTY = -0.1
41
+ FALSE_POSITIVE_PENALTY = -0.3
42
+ DONE_BONUS_MULTIPLIER = 0.5 # bonus = multiplier * (correct / total)
43
+
44
+
45
+ def compute_step_reward(
46
+ *,
47
+ is_correct: bool,
48
+ is_path_match: bool = False,
49
+ is_duplicate: bool,
50
+ is_done_signal: bool,
51
+ is_hint: bool = False,
52
+ correct_so_far: int,
53
+ total_violations: int,
54
+ ) -> RewardBreakdown:
55
+ """Compute reward for a single agent step.
56
+
57
+ Parameters
58
+ ----------
59
+ is_correct:
60
+ Whether the report fully matches a ground-truth violation (path + type).
61
+ is_path_match:
62
+ Whether the field_path matches a violation but violation_type is wrong.
63
+ is_duplicate:
64
+ Whether the agent already reported this violation.
65
+ is_done_signal:
66
+ Whether the agent submitted ``field_path='DONE'``.
67
+ is_hint:
68
+ Whether the agent submitted ``field_path='HINT'``.
69
+ correct_so_far:
70
+ Number of unique correct violations found before this step.
71
+ total_violations:
72
+ Total planted violations in the current scenario.
73
+
74
+ Returns
75
+ -------
76
+ RewardBreakdown
77
+ Contains the scalar reward and a human-readable explanation.
78
+ """
79
+ if is_hint:
80
+ return RewardBreakdown(
81
+ reward=HINT_PENALTY,
82
+ is_correct=False,
83
+ is_path_match=False,
84
+ is_duplicate=False,
85
+ is_false_positive=False,
86
+ is_done_signal=False,
87
+ is_hint=True,
88
+ explanation="Hint requested. -0.5 reward.",
89
+ )
90
+
91
+ if is_done_signal:
92
+ completeness = correct_so_far / max(total_violations, 1)
93
+ bonus = DONE_BONUS_MULTIPLIER * completeness
94
+ return RewardBreakdown(
95
+ reward=round(bonus, 4),
96
+ is_correct=False,
97
+ is_path_match=False,
98
+ is_duplicate=False,
99
+ is_false_positive=False,
100
+ is_done_signal=True,
101
+ is_hint=False,
102
+ explanation=(
103
+ f"Agent signalled DONE. "
104
+ f"Completeness {correct_so_far}/{total_violations} "
105
+ f"β†’ bonus {bonus:.2f}"
106
+ ),
107
+ )
108
+
109
+ if is_duplicate:
110
+ return RewardBreakdown(
111
+ reward=DUPLICATE_PENALTY,
112
+ is_correct=False,
113
+ is_path_match=False,
114
+ is_duplicate=True,
115
+ is_false_positive=False,
116
+ is_done_signal=False,
117
+ is_hint=False,
118
+ explanation="Duplicate violation report β€” already submitted.",
119
+ )
120
+
121
+ if is_correct:
122
+ return RewardBreakdown(
123
+ reward=CORRECT_VIOLATION_REWARD,
124
+ is_correct=True,
125
+ is_path_match=False,
126
+ is_duplicate=False,
127
+ is_false_positive=False,
128
+ is_done_signal=False,
129
+ is_hint=False,
130
+ explanation="Correct! Violation matches ground truth.",
131
+ )
132
+
133
+ if is_path_match:
134
+ return RewardBreakdown(
135
+ reward=PATH_MATCH_REWARD,
136
+ is_correct=False,
137
+ is_path_match=True,
138
+ is_duplicate=False,
139
+ is_false_positive=False,
140
+ is_done_signal=False,
141
+ is_hint=False,
142
+ explanation=(
143
+ "Correct field location! The field_path matches a violation, "
144
+ "but the violation_type is wrong. Try again with the right type."
145
+ ),
146
+ )
147
+
148
+ # False positive
149
+ return RewardBreakdown(
150
+ reward=FALSE_POSITIVE_PENALTY,
151
+ is_correct=False,
152
+ is_path_match=False,
153
+ is_duplicate=False,
154
+ is_false_positive=True,
155
+ is_done_signal=False,
156
+ is_hint=False,
157
+ explanation="False positive β€” no matching violation in ground truth.",
158
+ )
159
+
160
+
161
+ def compute_episode_score(correct_count: int, total_violations: int) -> float:
162
+ """Compute the final normalised score for the episode.
163
+
164
+ Returns a float in ``[0.0, 1.0]``.
165
+ """
166
+ if total_violations == 0:
167
+ return 1.0
168
+ return round(correct_count / total_violations, 4)
server/spec_generator.py ADDED
@@ -0,0 +1,1655 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Spec generator for the API Contract Validator Environment.
3
+
4
+ Generates OpenAPI specifications, payloads with planted violations,
5
+ and ground-truth violation records for four difficulty levels.
6
+
7
+ Each generator accepts an optional *seed* for deterministic randomisation:
8
+ - seed=None β†’ fixed canonical scenario (backward-compatible)
9
+ - seed=int β†’ reproducible randomised variant
10
+
11
+ This makes the environment suitable for both evaluation (fixed seed) and
12
+ training (varied seeds), which is the key distinction between a one-shot
13
+ evaluator and a genuine RL training environment.
14
+ """
15
+
16
+ import random
17
+ from dataclasses import dataclass
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ # Sentinel used in violation pool entries to signal "remove key from payload"
21
+ _REMOVE = object()
22
+
23
+
24
+ @dataclass
25
+ class PlantedViolation:
26
+ """A single ground-truth violation planted in a payload."""
27
+
28
+ field_path: str
29
+ violation_type: str
30
+ description: str
31
+ expected_value: str
32
+ actual_value: str
33
+
34
+
35
+ @dataclass
36
+ class TaskScenario:
37
+ """Everything the environment needs for one episode."""
38
+
39
+ task_name: str
40
+ task_description: str
41
+ api_spec: Dict[str, Any]
42
+ payload: Dict[str, Any]
43
+ violations: List[PlantedViolation]
44
+ max_steps: int
45
+
46
+
47
+ # ── Easy Task β€” pool-based randomisation ──────────────────────────────────
48
+ #
49
+ # The spec exposes 8 fields. Each episode seeds a random draw of 4 of the
50
+ # 8 possible violations, giving 70 unique episode combinations β€” enough to
51
+ # train an agent rather than just evaluate it once.
52
+
53
+ _EASY_SPEC: Dict[str, Any] = {
54
+ "openapi": "3.0.3",
55
+ "info": {"title": "User Service API", "version": "1.0.0"},
56
+ "paths": {
57
+ "/users": {
58
+ "post": {
59
+ "summary": "Create a new user",
60
+ "requestBody": {
61
+ "required": True,
62
+ "content": {
63
+ "application/json": {
64
+ "schema": {
65
+ "type": "object",
66
+ "required": ["username", "email", "age", "is_active"],
67
+ "properties": {
68
+ "username": {
69
+ "type": "string",
70
+ "minLength": 3,
71
+ "maxLength": 32,
72
+ },
73
+ "email": {
74
+ "type": "string",
75
+ "format": "email",
76
+ },
77
+ "age": {
78
+ "type": "integer",
79
+ "minimum": 0,
80
+ "maximum": 120,
81
+ },
82
+ "is_active": {"type": "boolean"},
83
+ "role": {
84
+ "type": "string",
85
+ "enum": ["admin", "editor", "viewer"],
86
+ },
87
+ "phone": {
88
+ "type": "string",
89
+ "pattern": r"^\+[1-9][0-9]{7,14}$",
90
+ },
91
+ "account_balance": {
92
+ "type": "number",
93
+ "minimum": 0.0,
94
+ },
95
+ "terms_accepted": {"type": "boolean"},
96
+ },
97
+ }
98
+ }
99
+ },
100
+ },
101
+ }
102
+ }
103
+ },
104
+ }
105
+
106
+ # Base payload β€” all fields valid. Pool entries mutate specific keys.
107
+ _EASY_VALID_PAYLOAD: Dict[str, Any] = {
108
+ "username": "alice_smith",
109
+ "email": "alice@example.com",
110
+ "age": 28,
111
+ "is_active": True,
112
+ "role": "editor",
113
+ "phone": "+14155551234",
114
+ "account_balance": 250.00,
115
+ "terms_accepted": True,
116
+ }
117
+
118
+ # Pool: (top-level key, bad value or _REMOVE, PlantedViolation)
119
+ # The first 4 entries reproduce the original fixed scenario (seed=None).
120
+ _EASY_POOL: List[Tuple[str, Any, PlantedViolation]] = [
121
+ (
122
+ "email",
123
+ _REMOVE,
124
+ PlantedViolation(
125
+ field_path="email",
126
+ violation_type="missing_required",
127
+ description="Required field 'email' is missing from the payload.",
128
+ expected_value="(present, type: string, format: email)",
129
+ actual_value="(missing)",
130
+ ),
131
+ ),
132
+ (
133
+ "age",
134
+ "twenty-five",
135
+ PlantedViolation(
136
+ field_path="age",
137
+ violation_type="type_mismatch",
138
+ description="Field 'age' should be integer but received string.",
139
+ expected_value="integer",
140
+ actual_value="string ('twenty-five')",
141
+ ),
142
+ ),
143
+ (
144
+ "is_active",
145
+ "yes",
146
+ PlantedViolation(
147
+ field_path="is_active",
148
+ violation_type="type_mismatch",
149
+ description="Field 'is_active' should be boolean but received string.",
150
+ expected_value="boolean",
151
+ actual_value="string ('yes')",
152
+ ),
153
+ ),
154
+ (
155
+ "role",
156
+ "superadmin",
157
+ PlantedViolation(
158
+ field_path="role",
159
+ violation_type="invalid_enum",
160
+ description="Value 'superadmin' is not in allowed enum [admin, editor, viewer].",
161
+ expected_value="one of: admin, editor, viewer",
162
+ actual_value="superadmin",
163
+ ),
164
+ ),
165
+ (
166
+ "phone",
167
+ "555-CALL-US",
168
+ PlantedViolation(
169
+ field_path="phone",
170
+ violation_type="format_error",
171
+ description="Field 'phone' contains letters; must match international format +E.164.",
172
+ expected_value=r"pattern: ^\+[1-9][0-9]{7,14}$",
173
+ actual_value="555-CALL-US",
174
+ ),
175
+ ),
176
+ (
177
+ "account_balance",
178
+ -50.00,
179
+ PlantedViolation(
180
+ field_path="account_balance",
181
+ violation_type="format_error",
182
+ description="Field 'account_balance' is -50.0, below the minimum of 0.",
183
+ expected_value="number >= 0",
184
+ actual_value="-50.0",
185
+ ),
186
+ ),
187
+ (
188
+ "terms_accepted",
189
+ "agreed",
190
+ PlantedViolation(
191
+ field_path="terms_accepted",
192
+ violation_type="type_mismatch",
193
+ description="Field 'terms_accepted' should be boolean but received string.",
194
+ expected_value="boolean",
195
+ actual_value="string ('agreed')",
196
+ ),
197
+ ),
198
+ (
199
+ "username",
200
+ "ab",
201
+ PlantedViolation(
202
+ field_path="username",
203
+ violation_type="format_error",
204
+ description="Field 'username' is 'ab' (length 2), below minLength of 3.",
205
+ expected_value="string, minLength: 3",
206
+ actual_value="'ab' (length 2)",
207
+ ),
208
+ ),
209
+ ]
210
+
211
+
212
+ def generate_easy_scenario(seed: Optional[int] = None) -> TaskScenario:
213
+ """Easy scenario: 4 violations sampled from a pool of 8.
214
+
215
+ seed=None always returns the original canonical 4 violations.
216
+ Any integer seed reproducibly draws a different subset.
217
+ """
218
+ if seed is None:
219
+ selected = _EASY_POOL[:4]
220
+ else:
221
+ rng = random.Random(seed)
222
+ selected = rng.sample(_EASY_POOL, 4)
223
+
224
+ payload = dict(_EASY_VALID_PAYLOAD)
225
+ violations: List[PlantedViolation] = []
226
+ for key, bad_val, planted in selected:
227
+ if bad_val is _REMOVE:
228
+ payload.pop(key, None)
229
+ else:
230
+ payload[key] = bad_val
231
+ violations.append(planted)
232
+
233
+ return TaskScenario(
234
+ task_name="find_type_mismatches",
235
+ task_description=(
236
+ "You are given an OpenAPI specification and an API request payload. "
237
+ "Find all violations in the payload: type mismatches, missing required "
238
+ "fields, invalid enum values, and format errors. "
239
+ "Report one violation per step using the field's dot-notation path. "
240
+ "Submit field_path='DONE' when finished."
241
+ ),
242
+ api_spec=_EASY_SPEC,
243
+ payload=payload,
244
+ violations=violations,
245
+ max_steps=10,
246
+ )
247
+
248
+
249
+ # ── Medium Task β€” two complete scenario variants ──────────────────────────
250
+
251
+ def _medium_scenario_a() -> TaskScenario:
252
+ """Original Order Service scenario with 7 nested violations."""
253
+ api_spec: Dict[str, Any] = {
254
+ "openapi": "3.0.3",
255
+ "info": {"title": "Order Service API", "version": "2.1.0"},
256
+ "paths": {
257
+ "/orders": {
258
+ "post": {
259
+ "summary": "Place a new order",
260
+ "requestBody": {
261
+ "required": True,
262
+ "content": {
263
+ "application/json": {
264
+ "schema": {
265
+ "type": "object",
266
+ "required": [
267
+ "customer",
268
+ "items",
269
+ "shipping_address",
270
+ "payment_method",
271
+ ],
272
+ "properties": {
273
+ "customer": {
274
+ "type": "object",
275
+ "required": ["id", "email"],
276
+ "properties": {
277
+ "id": {"type": "integer"},
278
+ "email": {
279
+ "type": "string",
280
+ "format": "email",
281
+ },
282
+ "loyalty_tier": {
283
+ "type": "string",
284
+ "enum": [
285
+ "bronze",
286
+ "silver",
287
+ "gold",
288
+ "platinum",
289
+ ],
290
+ },
291
+ },
292
+ },
293
+ "items": {
294
+ "type": "array",
295
+ "minItems": 1,
296
+ "items": {
297
+ "type": "object",
298
+ "required": [
299
+ "product_id",
300
+ "quantity",
301
+ "unit_price",
302
+ ],
303
+ "properties": {
304
+ "product_id": {"type": "string"},
305
+ "quantity": {
306
+ "type": "integer",
307
+ "minimum": 1,
308
+ },
309
+ "unit_price": {
310
+ "type": "number",
311
+ "minimum": 0,
312
+ },
313
+ },
314
+ },
315
+ },
316
+ "shipping_address": {
317
+ "type": "object",
318
+ "required": [
319
+ "street",
320
+ "city",
321
+ "zip_code",
322
+ "country",
323
+ ],
324
+ "properties": {
325
+ "street": {"type": "string"},
326
+ "city": {"type": "string"},
327
+ "zip_code": {"type": "string"},
328
+ "country": {
329
+ "type": "string",
330
+ "pattern": "^[A-Z]{2}$",
331
+ },
332
+ },
333
+ },
334
+ "payment_method": {
335
+ "type": "string",
336
+ "enum": [
337
+ "credit_card",
338
+ "debit_card",
339
+ "paypal",
340
+ "bank_transfer",
341
+ ],
342
+ },
343
+ },
344
+ }
345
+ }
346
+ },
347
+ },
348
+ }
349
+ }
350
+ },
351
+ }
352
+
353
+ payload: Dict[str, Any] = {
354
+ "customer": {
355
+ "id": "C-1001",
356
+ "loyalty_tier": "diamond",
357
+ },
358
+ "items": [
359
+ {"product_id": "SKU-001", "quantity": 2, "unit_price": 29.99},
360
+ {"product_id": 12345, "quantity": "three", "unit_price": 15.50},
361
+ ],
362
+ "shipping_address": {
363
+ "street": "123 Main St",
364
+ "city": "Springfield",
365
+ "zip_code": 62704,
366
+ "country": "usa",
367
+ },
368
+ "payment_method": "crypto",
369
+ }
370
+
371
+ violations = [
372
+ PlantedViolation(
373
+ field_path="customer.email",
374
+ violation_type="missing_required",
375
+ description="Required field 'email' missing from 'customer' object.",
376
+ expected_value="(present, type: string, format: email)",
377
+ actual_value="(missing)",
378
+ ),
379
+ PlantedViolation(
380
+ field_path="customer.id",
381
+ violation_type="type_mismatch",
382
+ description="Field 'customer.id' should be integer, got string.",
383
+ expected_value="integer",
384
+ actual_value="string ('C-1001')",
385
+ ),
386
+ PlantedViolation(
387
+ field_path="customer.loyalty_tier",
388
+ violation_type="invalid_enum",
389
+ description="Value 'diamond' not in enum [bronze, silver, gold, platinum].",
390
+ expected_value="one of: bronze, silver, gold, platinum",
391
+ actual_value="diamond",
392
+ ),
393
+ PlantedViolation(
394
+ field_path="items[1].product_id",
395
+ violation_type="type_mismatch",
396
+ description="Array item 'items[1].product_id' should be string, got integer.",
397
+ expected_value="string",
398
+ actual_value="integer (12345)",
399
+ ),
400
+ PlantedViolation(
401
+ field_path="items[1].quantity",
402
+ violation_type="type_mismatch",
403
+ description="Array item 'items[1].quantity' should be integer, got string.",
404
+ expected_value="integer",
405
+ actual_value="string ('three')",
406
+ ),
407
+ PlantedViolation(
408
+ field_path="shipping_address.zip_code",
409
+ violation_type="type_mismatch",
410
+ description="Field 'shipping_address.zip_code' should be string, got integer.",
411
+ expected_value="string",
412
+ actual_value="integer (62704)",
413
+ ),
414
+ PlantedViolation(
415
+ field_path="payment_method",
416
+ violation_type="invalid_enum",
417
+ description="Value 'crypto' not in enum [credit_card, debit_card, paypal, bank_transfer].",
418
+ expected_value="one of: credit_card, debit_card, paypal, bank_transfer",
419
+ actual_value="crypto",
420
+ ),
421
+ ]
422
+
423
+ return TaskScenario(
424
+ task_name="validate_nested_objects",
425
+ task_description=(
426
+ "You are given an OpenAPI specification and an API request payload "
427
+ "with nested objects and arrays. Find all violations including type "
428
+ "mismatches in nested fields, missing required fields inside objects, "
429
+ "invalid enum values, and type errors in array items. Use dot-notation "
430
+ "for nested paths (e.g. 'customer.email') and bracket notation for "
431
+ "arrays (e.g. 'items[1].quantity'). Submit field_path='DONE' when finished."
432
+ ),
433
+ api_spec=api_spec,
434
+ payload=payload,
435
+ violations=violations,
436
+ max_steps=15,
437
+ )
438
+
439
+
440
+ def _medium_scenario_b() -> TaskScenario:
441
+ """Alternate Event Booking scenario β€” different domain, 7 nested violations."""
442
+ api_spec: Dict[str, Any] = {
443
+ "openapi": "3.0.3",
444
+ "info": {"title": "Event Booking API", "version": "1.5.0"},
445
+ "paths": {
446
+ "/bookings": {
447
+ "post": {
448
+ "summary": "Book an event",
449
+ "requestBody": {
450
+ "required": True,
451
+ "content": {
452
+ "application/json": {
453
+ "schema": {
454
+ "type": "object",
455
+ "required": ["event", "attendees", "organizer", "payment"],
456
+ "properties": {
457
+ "event": {
458
+ "type": "object",
459
+ "required": ["id", "type", "capacity"],
460
+ "properties": {
461
+ "id": {"type": "integer"},
462
+ "type": {
463
+ "type": "string",
464
+ "enum": [
465
+ "concert",
466
+ "conference",
467
+ "sports",
468
+ "theater",
469
+ ],
470
+ },
471
+ "capacity": {
472
+ "type": "integer",
473
+ "minimum": 1,
474
+ },
475
+ "date": {
476
+ "type": "string",
477
+ "format": "date",
478
+ },
479
+ },
480
+ },
481
+ "attendees": {
482
+ "type": "array",
483
+ "minItems": 1,
484
+ "items": {
485
+ "type": "object",
486
+ "required": ["name", "email", "ticket_type"],
487
+ "properties": {
488
+ "name": {"type": "string"},
489
+ "email": {
490
+ "type": "string",
491
+ "format": "email",
492
+ },
493
+ "age": {
494
+ "type": "integer",
495
+ "minimum": 0,
496
+ "maximum": 120,
497
+ },
498
+ "ticket_type": {
499
+ "type": "string",
500
+ "enum": [
501
+ "vip",
502
+ "standard",
503
+ "economy",
504
+ ],
505
+ },
506
+ },
507
+ },
508
+ },
509
+ "organizer": {
510
+ "type": "object",
511
+ "required": ["name", "contact_email"],
512
+ "properties": {
513
+ "name": {"type": "string"},
514
+ "contact_email": {
515
+ "type": "string",
516
+ "format": "email",
517
+ },
518
+ "phone": {"type": "string"},
519
+ },
520
+ },
521
+ "payment": {
522
+ "type": "object",
523
+ "required": ["method", "total_amount"],
524
+ "properties": {
525
+ "method": {
526
+ "type": "string",
527
+ "enum": [
528
+ "card",
529
+ "invoice",
530
+ "cash",
531
+ ],
532
+ },
533
+ "total_amount": {
534
+ "type": "number",
535
+ "minimum": 0,
536
+ },
537
+ },
538
+ },
539
+ },
540
+ }
541
+ }
542
+ },
543
+ },
544
+ }
545
+ }
546
+ },
547
+ }
548
+
549
+ payload: Dict[str, Any] = {
550
+ "event": {
551
+ "id": "EVT-001",
552
+ "type": "festival",
553
+ "capacity": 500,
554
+ "date": "2025-06-15",
555
+ },
556
+ "attendees": [
557
+ {
558
+ "name": "Sarah Chen",
559
+ "ticket_type": "vip",
560
+ },
561
+ {
562
+ "name": "Mark Rivera",
563
+ "email": "mark@example.com",
564
+ "age": 135,
565
+ "ticket_type": "premium",
566
+ },
567
+ ],
568
+ "organizer": {
569
+ "name": "LiveNation Events",
570
+ "contact_email": "organizer.example.com",
571
+ "phone": "+442071234567",
572
+ },
573
+ "payment": {
574
+ "method": "card",
575
+ "total_amount": "free",
576
+ },
577
+ }
578
+
579
+ violations = [
580
+ PlantedViolation(
581
+ field_path="event.id",
582
+ violation_type="type_mismatch",
583
+ description="Field 'event.id' should be integer, got string.",
584
+ expected_value="integer",
585
+ actual_value="string ('EVT-001')",
586
+ ),
587
+ PlantedViolation(
588
+ field_path="event.type",
589
+ violation_type="invalid_enum",
590
+ description="Value 'festival' not in enum [concert, conference, sports, theater].",
591
+ expected_value="one of: concert, conference, sports, theater",
592
+ actual_value="festival",
593
+ ),
594
+ PlantedViolation(
595
+ field_path="attendees[0].email",
596
+ violation_type="missing_required",
597
+ description="Required field 'email' missing from attendees[0].",
598
+ expected_value="(present, type: string, format: email)",
599
+ actual_value="(missing)",
600
+ ),
601
+ PlantedViolation(
602
+ field_path="attendees[1].age",
603
+ violation_type="format_error",
604
+ description="Field 'attendees[1].age' is 135, exceeds maximum of 120.",
605
+ expected_value="integer, maximum: 120",
606
+ actual_value="135",
607
+ ),
608
+ PlantedViolation(
609
+ field_path="attendees[1].ticket_type",
610
+ violation_type="invalid_enum",
611
+ description="Value 'premium' not in enum [vip, standard, economy].",
612
+ expected_value="one of: vip, standard, economy",
613
+ actual_value="premium",
614
+ ),
615
+ PlantedViolation(
616
+ field_path="organizer.contact_email",
617
+ violation_type="format_error",
618
+ description="Field 'organizer.contact_email' is not a valid email (missing @).",
619
+ expected_value="string, format: email",
620
+ actual_value="'organizer.example.com'",
621
+ ),
622
+ PlantedViolation(
623
+ field_path="payment.total_amount",
624
+ violation_type="type_mismatch",
625
+ description="Field 'payment.total_amount' should be number, got string.",
626
+ expected_value="number",
627
+ actual_value="string ('free')",
628
+ ),
629
+ ]
630
+
631
+ return TaskScenario(
632
+ task_name="validate_nested_objects",
633
+ task_description=(
634
+ "You are given an OpenAPI specification and an API request payload "
635
+ "with nested objects and arrays. Find all violations including type "
636
+ "mismatches in nested fields, missing required fields inside objects, "
637
+ "invalid enum values, format errors, and type errors in array items. "
638
+ "Use dot-notation for nested paths (e.g. 'organizer.contact_email') "
639
+ "and bracket notation for arrays (e.g. 'attendees[1].age'). "
640
+ "Submit field_path='DONE' when finished."
641
+ ),
642
+ api_spec=api_spec,
643
+ payload=payload,
644
+ violations=violations,
645
+ max_steps=15,
646
+ )
647
+
648
+
649
+ def generate_medium_scenario(seed: Optional[int] = None) -> TaskScenario:
650
+ """Medium scenario: two complete variants selected by seed.
651
+
652
+ seed=None or even seed β†’ Order Service (variant A, original).
653
+ Odd seed β†’ Event Booking (variant B).
654
+ """
655
+ if seed is None or seed % 2 == 0:
656
+ return _medium_scenario_a()
657
+ return _medium_scenario_b()
658
+
659
+
660
+ # ── Hard Task β€” breaking changes (fixed, no variant needed) ───────────────
661
+
662
+
663
+ def generate_hard_scenario(seed: Optional[int] = None) -> TaskScenario:
664
+ """Hard scenario: 9 breaking changes between two API spec versions.
665
+
666
+ The task is inherently complex (spec-diffing); a single well-designed
667
+ scenario is more valuable than noisy variants. seed is accepted for
668
+ API uniformity but not used.
669
+ """
670
+ api_spec: Dict[str, Any] = {
671
+ "description": (
672
+ "Compare v1 (old) and v2 (new) of the Product Catalog API. "
673
+ "Identify all BREAKING changes that would cause existing v1 "
674
+ "clients to fail when calling the v2 API."
675
+ ),
676
+ "v1": {
677
+ "openapi": "3.0.3",
678
+ "info": {"title": "Product Catalog API", "version": "1.0.0"},
679
+ "paths": {
680
+ "/products": {
681
+ "post": {
682
+ "summary": "Create a product",
683
+ "requestBody": {
684
+ "required": True,
685
+ "content": {
686
+ "application/json": {
687
+ "schema": {
688
+ "type": "object",
689
+ "required": ["name", "price", "category"],
690
+ "properties": {
691
+ "name": {"type": "string"},
692
+ "description": {"type": "string"},
693
+ "price": {"type": "number"},
694
+ "category": {
695
+ "type": "string",
696
+ "enum": [
697
+ "electronics",
698
+ "clothing",
699
+ "books",
700
+ "home",
701
+ "sports",
702
+ ],
703
+ },
704
+ "tags": {
705
+ "type": "array",
706
+ "items": {"type": "string"},
707
+ },
708
+ "weight_kg": {"type": "number"},
709
+ "supplier_code": {"type": "string"},
710
+ },
711
+ }
712
+ }
713
+ },
714
+ },
715
+ },
716
+ "get": {
717
+ "summary": "List products",
718
+ "responses": {
719
+ "200": {
720
+ "content": {
721
+ "application/json": {
722
+ "schema": {
723
+ "type": "array",
724
+ "items": {
725
+ "type": "object",
726
+ "properties": {
727
+ "id": {"type": "integer"},
728
+ "name": {"type": "string"},
729
+ "price": {"type": "number"},
730
+ "category": {"type": "string"},
731
+ "discount_percent": {
732
+ "type": "number"
733
+ },
734
+ },
735
+ },
736
+ }
737
+ }
738
+ }
739
+ }
740
+ },
741
+ },
742
+ }
743
+ },
744
+ },
745
+ "v2": {
746
+ "openapi": "3.0.3",
747
+ "info": {"title": "Product Catalog API", "version": "2.0.0"},
748
+ "paths": {
749
+ "/products": {
750
+ "post": {
751
+ "summary": "Create a product",
752
+ "requestBody": {
753
+ "required": True,
754
+ "content": {
755
+ "application/json": {
756
+ "schema": {
757
+ "type": "object",
758
+ "required": [
759
+ "name",
760
+ "price",
761
+ "category",
762
+ "sku",
763
+ ],
764
+ "properties": {
765
+ "name": {"type": "string"},
766
+ "description": {"type": "string"},
767
+ "price": {"type": "string"},
768
+ "category": {
769
+ "type": "string",
770
+ "enum": [
771
+ "electronics",
772
+ "clothing",
773
+ "books",
774
+ ],
775
+ },
776
+ "tags": {"type": "integer"},
777
+ "sku": {"type": "string"},
778
+ "weight_grams": {"type": "integer"},
779
+ },
780
+ }
781
+ }
782
+ },
783
+ },
784
+ },
785
+ "get": {
786
+ "summary": "List products",
787
+ "responses": {
788
+ "200": {
789
+ "content": {
790
+ "application/json": {
791
+ "schema": {
792
+ "type": "array",
793
+ "items": {
794
+ "type": "object",
795
+ "properties": {
796
+ "id": {"type": "string"},
797
+ "name": {"type": "string"},
798
+ "price": {"type": "string"},
799
+ "category": {"type": "string"},
800
+ },
801
+ },
802
+ }
803
+ }
804
+ }
805
+ }
806
+ },
807
+ },
808
+ }
809
+ },
810
+ },
811
+ }
812
+
813
+ payload: Dict[str, Any] = {
814
+ "name": "Wireless Headphones",
815
+ "description": "Noise-cancelling over-ear headphones",
816
+ "price": 79.99,
817
+ "category": "sports",
818
+ "tags": ["audio", "wireless"],
819
+ "weight_kg": 0.35,
820
+ "supplier_code": "SUP-442",
821
+ }
822
+
823
+ violations = [
824
+ PlantedViolation(
825
+ field_path="POST /products.price",
826
+ violation_type="breaking_change",
827
+ description="Field 'price' type changed from 'number' to 'string' in v2.",
828
+ expected_value="number (v1)",
829
+ actual_value="string (v2)",
830
+ ),
831
+ PlantedViolation(
832
+ field_path="POST /products.category",
833
+ violation_type="breaking_change",
834
+ description="Enum for 'category' narrowed: 'home' and 'sports' removed in v2.",
835
+ expected_value="enum: electronics, clothing, books, home, sports (v1)",
836
+ actual_value="enum: electronics, clothing, books (v2)",
837
+ ),
838
+ PlantedViolation(
839
+ field_path="POST /products.tags",
840
+ violation_type="breaking_change",
841
+ description="Field 'tags' type changed from 'array of strings' to 'integer' in v2.",
842
+ expected_value="array of strings (v1)",
843
+ actual_value="integer (v2)",
844
+ ),
845
+ PlantedViolation(
846
+ field_path="POST /products.sku",
847
+ violation_type="breaking_change",
848
+ description="New required field 'sku' added in v2 β€” breaks existing clients.",
849
+ expected_value="(not required in v1)",
850
+ actual_value="required string (v2)",
851
+ ),
852
+ PlantedViolation(
853
+ field_path="POST /products.supplier_code",
854
+ violation_type="breaking_change",
855
+ description="Field 'supplier_code' removed in v2 β€” clients sending it get rejected.",
856
+ expected_value="string (v1)",
857
+ actual_value="(removed in v2)",
858
+ ),
859
+ PlantedViolation(
860
+ field_path="POST /products.weight_kg",
861
+ violation_type="breaking_change",
862
+ description="Field 'weight_kg' removed; replaced by 'weight_grams' with different type.",
863
+ expected_value="number 'weight_kg' (v1)",
864
+ actual_value="integer 'weight_grams' (v2)",
865
+ ),
866
+ PlantedViolation(
867
+ field_path="GET /products[].id",
868
+ violation_type="breaking_change",
869
+ description="Response field 'id' type changed from 'integer' to 'string' in v2.",
870
+ expected_value="integer (v1)",
871
+ actual_value="string (v2)",
872
+ ),
873
+ PlantedViolation(
874
+ field_path="GET /products[].price",
875
+ violation_type="breaking_change",
876
+ description="Response field 'price' type changed from 'number' to 'string' in v2.",
877
+ expected_value="number (v1)",
878
+ actual_value="string (v2)",
879
+ ),
880
+ PlantedViolation(
881
+ field_path="GET /products[].discount_percent",
882
+ violation_type="breaking_change",
883
+ description="Response field 'discount_percent' removed in v2 β€” dependent clients break.",
884
+ expected_value="number (v1)",
885
+ actual_value="(removed in v2)",
886
+ ),
887
+ ]
888
+
889
+ return TaskScenario(
890
+ task_name="detect_breaking_changes",
891
+ task_description=(
892
+ "You are given two versions (v1 and v2) of a Product Catalog API "
893
+ "specification, plus a sample v1 client payload. Identify all "
894
+ "BREAKING changes between v1 and v2 that would cause existing "
895
+ "clients to fail. Breaking changes include: type changes, removed "
896
+ "fields, narrowed enums, new required fields, and removed response "
897
+ "fields. Use the format 'METHOD /path.field' for paths "
898
+ "(e.g. 'POST /products.price'). Submit field_path='DONE' when finished."
899
+ ),
900
+ api_spec=api_spec,
901
+ payload=payload,
902
+ violations=violations,
903
+ max_steps=20,
904
+ )
905
+
906
+
907
+ # ── Expert Task β€” response schema format validation ───────────────────────
908
+ #
909
+ # Agents must validate an API *response* (not a request) against the spec.
910
+ # Violations are subtle: pattern mismatches, out-of-range numerics, wrong
911
+ # date formats, and invalid enum values scattered across nested objects and
912
+ # arrays. Two complete variants are provided via seed selection.
913
+
914
+ _RESPONSE_SPEC: Dict[str, Any] = {
915
+ "openapi": "3.0.3",
916
+ "info": {"title": "E-Commerce Order API", "version": "3.0.0"},
917
+ "paths": {
918
+ "/orders/{order_id}": {
919
+ "get": {
920
+ "summary": "Retrieve a single order",
921
+ "responses": {
922
+ "200": {
923
+ "description": "Order details",
924
+ "content": {
925
+ "application/json": {
926
+ "schema": {
927
+ "type": "object",
928
+ "required": [
929
+ "order_id",
930
+ "created_at",
931
+ "status",
932
+ "customer",
933
+ "items",
934
+ "billing",
935
+ ],
936
+ "properties": {
937
+ "order_id": {
938
+ "type": "string",
939
+ "pattern": "^ORD-[0-9]{6}$",
940
+ "description": "Must match ORD-NNNNNN exactly",
941
+ },
942
+ "created_at": {
943
+ "type": "string",
944
+ "format": "date-time",
945
+ "description": "ISO 8601 date-time, e.g. 2024-01-15T10:30:00Z",
946
+ },
947
+ "promised_delivery_date": {
948
+ "type": "string",
949
+ "format": "date",
950
+ "description": "ISO 8601 date, e.g. 2024-03-20",
951
+ },
952
+ "status": {
953
+ "type": "string",
954
+ "enum": [
955
+ "pending",
956
+ "processing",
957
+ "shipped",
958
+ "delivered",
959
+ "cancelled",
960
+ ],
961
+ },
962
+ "customer": {
963
+ "type": "object",
964
+ "required": ["email", "phone", "loyalty_points"],
965
+ "properties": {
966
+ "email": {
967
+ "type": "string",
968
+ "format": "email",
969
+ },
970
+ "phone": {
971
+ "type": "string",
972
+ "pattern": r"^\+[1-9][0-9]{7,14}$",
973
+ "description": "E.164 format, e.g. +14155552671",
974
+ },
975
+ "loyalty_points": {
976
+ "type": "integer",
977
+ "minimum": 0,
978
+ },
979
+ },
980
+ },
981
+ "items": {
982
+ "type": "array",
983
+ "minItems": 1,
984
+ "items": {
985
+ "type": "object",
986
+ "required": [
987
+ "sku",
988
+ "unit_price",
989
+ "quantity",
990
+ "discount_rate",
991
+ ],
992
+ "properties": {
993
+ "sku": {
994
+ "type": "string",
995
+ "pattern": "^SKU-[A-Z0-9]{6}$",
996
+ "description": "Must match SKU-XXXXXX",
997
+ },
998
+ "unit_price": {
999
+ "type": "number",
1000
+ "minimum": 0.01,
1001
+ },
1002
+ "quantity": {
1003
+ "type": "integer",
1004
+ "minimum": 1,
1005
+ "maximum": 100,
1006
+ },
1007
+ "discount_rate": {
1008
+ "type": "number",
1009
+ "minimum": 0,
1010
+ "maximum": 1,
1011
+ },
1012
+ },
1013
+ },
1014
+ },
1015
+ "billing": {
1016
+ "type": "object",
1017
+ "required": ["subtotal", "tax_rate", "total"],
1018
+ "properties": {
1019
+ "subtotal": {
1020
+ "type": "number",
1021
+ "minimum": 0,
1022
+ },
1023
+ "tax_rate": {
1024
+ "type": "number",
1025
+ "minimum": 0,
1026
+ "maximum": 0.5,
1027
+ "description": "Fraction 0.0–0.5 (max 50%)",
1028
+ },
1029
+ "total": {
1030
+ "type": "number",
1031
+ "minimum": 0,
1032
+ },
1033
+ },
1034
+ },
1035
+ "tracking_code": {
1036
+ "type": "string",
1037
+ "pattern": "^[A-Z]{2}[0-9]{9}[A-Z]{2}$",
1038
+ "description": "2 upper letters + 9 digits + 2 upper letters",
1039
+ },
1040
+ "estimated_days": {
1041
+ "type": "integer",
1042
+ "minimum": 1,
1043
+ "maximum": 30,
1044
+ },
1045
+ },
1046
+ }
1047
+ }
1048
+ },
1049
+ }
1050
+ },
1051
+ }
1052
+ }
1053
+ },
1054
+ }
1055
+
1056
+ _RESPONSE_TASK_DESCRIPTION = (
1057
+ "You are given an OpenAPI response schema for GET /orders/{order_id} "
1058
+ "and an actual API response. Validate the response against the schema. "
1059
+ "Find all violations including: format errors (invalid date/time formats, "
1060
+ "pattern mismatches, out-of-range numeric values), type mismatches, and "
1061
+ "invalid enum values. These are subtle β€” read the spec constraints carefully "
1062
+ "(patterns, minimum/maximum, format strings). "
1063
+ "Use dot-notation for nested paths and bracket notation for arrays. "
1064
+ "Submit field_path='DONE' when finished."
1065
+ )
1066
+
1067
+
1068
+ def _response_scenario_a() -> TaskScenario:
1069
+ """Expert variant A: 10 format/constraint violations in an order response."""
1070
+ payload: Dict[str, Any] = {
1071
+ "order_id": "ORDER-123456", # format_error: must be ORD-NNNNNN
1072
+ "created_at": "2024-01-15 10:30:00", # format_error: missing T separator (not ISO 8601)
1073
+ "promised_delivery_date": "15/01/2024", # format_error: DD/MM/YYYY not YYYY-MM-DD
1074
+ "status": "refunded", # invalid_enum
1075
+ "customer": {
1076
+ "email": "customer@@example.com", # format_error: double @
1077
+ "phone": "555-1234", # format_error: not E.164
1078
+ "loyalty_points": 1500, # valid
1079
+ },
1080
+ "items": [
1081
+ {
1082
+ "sku": "SKU-AB1234", # valid
1083
+ "unit_price": 0.00, # format_error: below minimum 0.01
1084
+ "quantity": 2,
1085
+ "discount_rate": 0.1,
1086
+ },
1087
+ {
1088
+ "sku": "SKU-CD5678", # valid
1089
+ "unit_price": 49.99,
1090
+ "quantity": 150, # format_error: exceeds maximum 100
1091
+ "discount_rate": 0.15,
1092
+ },
1093
+ ],
1094
+ "billing": {
1095
+ "subtotal": 149.97,
1096
+ "tax_rate": 0.65, # format_error: exceeds maximum 0.5
1097
+ "total": 248.95,
1098
+ },
1099
+ "tracking_code": "TRACK123456789", # format_error: wrong pattern
1100
+ "estimated_days": 5,
1101
+ }
1102
+
1103
+ violations = [
1104
+ PlantedViolation(
1105
+ field_path="order_id",
1106
+ violation_type="format_error",
1107
+ description="'ORDER-123456' does not match required pattern ^ORD-[0-9]{6}$.",
1108
+ expected_value="string matching ^ORD-[0-9]{6}$",
1109
+ actual_value="'ORDER-123456'",
1110
+ ),
1111
+ PlantedViolation(
1112
+ field_path="created_at",
1113
+ violation_type="format_error",
1114
+ description="'2024-01-15 10:30:00' is not valid ISO 8601 date-time (missing T separator).",
1115
+ expected_value="date-time, e.g. 2024-01-15T10:30:00Z",
1116
+ actual_value="'2024-01-15 10:30:00'",
1117
+ ),
1118
+ PlantedViolation(
1119
+ field_path="promised_delivery_date",
1120
+ violation_type="format_error",
1121
+ description="'15/01/2024' is not valid ISO 8601 date (expected YYYY-MM-DD).",
1122
+ expected_value="date, e.g. 2024-01-15",
1123
+ actual_value="'15/01/2024'",
1124
+ ),
1125
+ PlantedViolation(
1126
+ field_path="status",
1127
+ violation_type="invalid_enum",
1128
+ description="Value 'refunded' not in enum [pending, processing, shipped, delivered, cancelled].",
1129
+ expected_value="one of: pending, processing, shipped, delivered, cancelled",
1130
+ actual_value="'refunded'",
1131
+ ),
1132
+ PlantedViolation(
1133
+ field_path="customer.email",
1134
+ violation_type="format_error",
1135
+ description="'customer@@example.com' is not a valid email (double @ symbol).",
1136
+ expected_value="string, format: email",
1137
+ actual_value="'customer@@example.com'",
1138
+ ),
1139
+ PlantedViolation(
1140
+ field_path="customer.phone",
1141
+ violation_type="format_error",
1142
+ description="'555-1234' does not match E.164 pattern ^\\+[1-9][0-9]{7,14}$.",
1143
+ expected_value=r"string matching ^\+[1-9][0-9]{7,14}$",
1144
+ actual_value="'555-1234'",
1145
+ ),
1146
+ PlantedViolation(
1147
+ field_path="items[0].unit_price",
1148
+ violation_type="format_error",
1149
+ description="'items[0].unit_price' is 0.0, below minimum of 0.01.",
1150
+ expected_value="number >= 0.01",
1151
+ actual_value="0.0",
1152
+ ),
1153
+ PlantedViolation(
1154
+ field_path="items[1].quantity",
1155
+ violation_type="format_error",
1156
+ description="'items[1].quantity' is 150, exceeds maximum of 100.",
1157
+ expected_value="integer, maximum: 100",
1158
+ actual_value="150",
1159
+ ),
1160
+ PlantedViolation(
1161
+ field_path="billing.tax_rate",
1162
+ violation_type="format_error",
1163
+ description="'billing.tax_rate' is 0.65, exceeds maximum of 0.5 (50%).",
1164
+ expected_value="number, maximum: 0.5",
1165
+ actual_value="0.65",
1166
+ ),
1167
+ PlantedViolation(
1168
+ field_path="tracking_code",
1169
+ violation_type="format_error",
1170
+ description="'TRACK123456789' does not match pattern ^[A-Z]{2}[0-9]{9}[A-Z]{2}$.",
1171
+ expected_value="string matching ^[A-Z]{2}[0-9]{9}[A-Z]{2}$",
1172
+ actual_value="'TRACK123456789'",
1173
+ ),
1174
+ ]
1175
+
1176
+ return TaskScenario(
1177
+ task_name="validate_response_schema",
1178
+ task_description=_RESPONSE_TASK_DESCRIPTION,
1179
+ api_spec=_RESPONSE_SPEC,
1180
+ payload=payload,
1181
+ violations=violations,
1182
+ max_steps=25,
1183
+ )
1184
+
1185
+
1186
+ def _response_scenario_b() -> TaskScenario:
1187
+ """Expert variant B: 10 different format/constraint violations β€” harder to spot."""
1188
+ payload: Dict[str, Any] = {
1189
+ "order_id": "ORD-12AB56", # format_error: non-digits in numeric portion
1190
+ "created_at": "2024-13-01T10:30:00Z", # format_error: month 13 is invalid
1191
+ "promised_delivery_date": "2024-00-15", # format_error: month 0 is invalid
1192
+ "status": "returned", # invalid_enum
1193
+ "customer": {
1194
+ "email": "user@", # format_error: incomplete email, no domain
1195
+ "phone": "+14155551234", # valid
1196
+ "loyalty_points": "1500", # type_mismatch: string instead of integer
1197
+ },
1198
+ "items": [
1199
+ {
1200
+ "sku": "PROD-AB1234", # format_error: wrong prefix (PROD vs SKU)
1201
+ "unit_price": 29.99,
1202
+ "quantity": 2,
1203
+ "discount_rate": 1.5, # format_error: exceeds maximum 1.0
1204
+ },
1205
+ {
1206
+ "sku": "SKU-CD5678", # valid
1207
+ "unit_price": 49.99,
1208
+ "quantity": 3,
1209
+ "discount_rate": 0.0,
1210
+ },
1211
+ ],
1212
+ "billing": {
1213
+ "subtotal": "99.50", # type_mismatch: string instead of number
1214
+ "tax_rate": 0.18,
1215
+ "total": 117.41,
1216
+ },
1217
+ "tracking_code": "AB123456789CD", # valid β€” matches ^[A-Z]{2}[0-9]{9}[A-Z]{2}$
1218
+ "estimated_days": 0, # format_error: below minimum 1
1219
+ }
1220
+
1221
+ violations = [
1222
+ PlantedViolation(
1223
+ field_path="order_id",
1224
+ violation_type="format_error",
1225
+ description="'ORD-12AB56' does not match ^ORD-[0-9]{6}$ (non-digit chars 'AB').",
1226
+ expected_value="string matching ^ORD-[0-9]{6}$",
1227
+ actual_value="'ORD-12AB56'",
1228
+ ),
1229
+ PlantedViolation(
1230
+ field_path="created_at",
1231
+ violation_type="format_error",
1232
+ description="'2024-13-01T10:30:00Z' has invalid month 13 β€” not a valid date-time.",
1233
+ expected_value="date-time with valid calendar date",
1234
+ actual_value="'2024-13-01T10:30:00Z'",
1235
+ ),
1236
+ PlantedViolation(
1237
+ field_path="promised_delivery_date",
1238
+ violation_type="format_error",
1239
+ description="'2024-00-15' has month 0, which is not a valid calendar month.",
1240
+ expected_value="date with valid month (01–12)",
1241
+ actual_value="'2024-00-15'",
1242
+ ),
1243
+ PlantedViolation(
1244
+ field_path="status",
1245
+ violation_type="invalid_enum",
1246
+ description="Value 'returned' not in enum [pending, processing, shipped, delivered, cancelled].",
1247
+ expected_value="one of: pending, processing, shipped, delivered, cancelled",
1248
+ actual_value="'returned'",
1249
+ ),
1250
+ PlantedViolation(
1251
+ field_path="customer.email",
1252
+ violation_type="format_error",
1253
+ description="'user@' is not a valid email address (missing domain after @).",
1254
+ expected_value="string, format: email",
1255
+ actual_value="'user@'",
1256
+ ),
1257
+ PlantedViolation(
1258
+ field_path="customer.loyalty_points",
1259
+ violation_type="type_mismatch",
1260
+ description="Field 'customer.loyalty_points' should be integer, got string.",
1261
+ expected_value="integer",
1262
+ actual_value="string ('1500')",
1263
+ ),
1264
+ PlantedViolation(
1265
+ field_path="items[0].sku",
1266
+ violation_type="format_error",
1267
+ description="'PROD-AB1234' does not match required pattern ^SKU-[A-Z0-9]{6}$.",
1268
+ expected_value="string matching ^SKU-[A-Z0-9]{6}$",
1269
+ actual_value="'PROD-AB1234'",
1270
+ ),
1271
+ PlantedViolation(
1272
+ field_path="items[0].discount_rate",
1273
+ violation_type="format_error",
1274
+ description="'items[0].discount_rate' is 1.5, exceeds maximum of 1.0.",
1275
+ expected_value="number, maximum: 1.0",
1276
+ actual_value="1.5",
1277
+ ),
1278
+ PlantedViolation(
1279
+ field_path="billing.subtotal",
1280
+ violation_type="type_mismatch",
1281
+ description="Field 'billing.subtotal' should be number, got string.",
1282
+ expected_value="number",
1283
+ actual_value="string ('99.50')",
1284
+ ),
1285
+ PlantedViolation(
1286
+ field_path="estimated_days",
1287
+ violation_type="format_error",
1288
+ description="'estimated_days' is 0, below minimum of 1.",
1289
+ expected_value="integer, minimum: 1",
1290
+ actual_value="0",
1291
+ ),
1292
+ ]
1293
+
1294
+ return TaskScenario(
1295
+ task_name="validate_response_schema",
1296
+ task_description=_RESPONSE_TASK_DESCRIPTION,
1297
+ api_spec=_RESPONSE_SPEC,
1298
+ payload=payload,
1299
+ violations=violations,
1300
+ max_steps=25,
1301
+ )
1302
+
1303
+
1304
+ def generate_format_validation_scenario(seed: Optional[int] = None) -> TaskScenario:
1305
+ """Expert scenario: validate an API response for format/constraint violations.
1306
+
1307
+ seed=None or even seed β†’ variant A.
1308
+ Odd seed β†’ variant B (different set of violations, same spec).
1309
+ """
1310
+ if seed is None or seed % 2 == 0:
1311
+ return _response_scenario_a()
1312
+ return _response_scenario_b()
1313
+
1314
+
1315
+ # ── Expert Task 2 β€” cross-field constraint validation ─────────────────────
1316
+ #
1317
+ # Violations require multi-field reasoning: arithmetic, date ordering, and
1318
+ # conditional requirements. Standard schema validators cannot catch these;
1319
+ # an agent must actively compute and cross-reference values.
1320
+
1321
+
1322
+ def generate_cross_field_scenario(seed: Optional[int] = None) -> TaskScenario:
1323
+ """Expert scenario: 7 cross-field constraint violations.
1324
+
1325
+ Requires the agent to:
1326
+ - Verify arithmetic (line_total = quantity * unit_price)
1327
+ - Check date ordering (due_date after invoice_date)
1328
+ - Validate computed totals (tax_amount = subtotal * tax_rate)
1329
+ - Enforce conditional rules (trial accounts: discount_amount = 0)
1330
+ - Count array elements (item_count = len(line_items))
1331
+
1332
+ seed is accepted for API uniformity but not used (single canonical scenario).
1333
+ """
1334
+ api_spec: Dict[str, Any] = {
1335
+ "openapi": "3.0.3",
1336
+ "info": {"title": "Invoice Service API", "version": "1.0.0"},
1337
+ "paths": {
1338
+ "/invoices": {
1339
+ "post": {
1340
+ "summary": "Create a new invoice",
1341
+ "description": (
1342
+ "Cross-field constraints (not expressible in JSON Schema): "
1343
+ "(1) due_date must be strictly after invoice_date. "
1344
+ "(2) Each line_items[N].line_total must equal quantity * unit_price. "
1345
+ "(3) billing.subtotal must equal the sum of all line_items[N].line_total values. "
1346
+ "(4) billing.tax_amount must equal billing.subtotal * billing.tax_rate. "
1347
+ "(5) billing.total must equal billing.subtotal + billing.tax_amount - billing.discount_amount. "
1348
+ "(6) billing.item_count must equal the number of elements in the line_items array. "
1349
+ "(7) If customer.account_type is 'trial', billing.discount_amount must be 0."
1350
+ ),
1351
+ "requestBody": {
1352
+ "required": True,
1353
+ "content": {
1354
+ "application/json": {
1355
+ "schema": {
1356
+ "type": "object",
1357
+ "required": [
1358
+ "invoice_date",
1359
+ "due_date",
1360
+ "customer",
1361
+ "line_items",
1362
+ "billing",
1363
+ ],
1364
+ "properties": {
1365
+ "invoice_date": {
1366
+ "type": "string",
1367
+ "format": "date",
1368
+ },
1369
+ "due_date": {
1370
+ "type": "string",
1371
+ "format": "date",
1372
+ "description": "Must be strictly after invoice_date",
1373
+ },
1374
+ "customer": {
1375
+ "type": "object",
1376
+ "required": ["id", "name", "account_type"],
1377
+ "properties": {
1378
+ "id": {"type": "integer"},
1379
+ "name": {"type": "string"},
1380
+ "account_type": {
1381
+ "type": "string",
1382
+ "enum": [
1383
+ "trial",
1384
+ "standard",
1385
+ "premium",
1386
+ "enterprise",
1387
+ ],
1388
+ },
1389
+ },
1390
+ },
1391
+ "line_items": {
1392
+ "type": "array",
1393
+ "minItems": 1,
1394
+ "items": {
1395
+ "type": "object",
1396
+ "required": [
1397
+ "description",
1398
+ "quantity",
1399
+ "unit_price",
1400
+ "line_total",
1401
+ ],
1402
+ "properties": {
1403
+ "description": {"type": "string"},
1404
+ "quantity": {
1405
+ "type": "integer",
1406
+ "minimum": 1,
1407
+ },
1408
+ "unit_price": {
1409
+ "type": "number",
1410
+ "minimum": 0,
1411
+ },
1412
+ "line_total": {
1413
+ "type": "number",
1414
+ "description": "Must equal quantity * unit_price",
1415
+ },
1416
+ },
1417
+ },
1418
+ },
1419
+ "billing": {
1420
+ "type": "object",
1421
+ "required": [
1422
+ "subtotal",
1423
+ "tax_rate",
1424
+ "tax_amount",
1425
+ "discount_amount",
1426
+ "total",
1427
+ "item_count",
1428
+ "currency",
1429
+ ],
1430
+ "properties": {
1431
+ "subtotal": {
1432
+ "type": "number",
1433
+ "minimum": 0,
1434
+ "description": "Sum of all line_items[N].line_total",
1435
+ },
1436
+ "tax_rate": {
1437
+ "type": "number",
1438
+ "minimum": 0,
1439
+ "maximum": 1,
1440
+ },
1441
+ "tax_amount": {
1442
+ "type": "number",
1443
+ "description": "Must equal subtotal * tax_rate",
1444
+ },
1445
+ "discount_amount": {
1446
+ "type": "number",
1447
+ "minimum": 0,
1448
+ "description": "Must be 0 when customer.account_type is 'trial'",
1449
+ },
1450
+ "total": {
1451
+ "type": "number",
1452
+ "description": "Must equal subtotal + tax_amount - discount_amount",
1453
+ },
1454
+ "item_count": {
1455
+ "type": "integer",
1456
+ "description": "Must equal number of elements in line_items",
1457
+ },
1458
+ "currency": {
1459
+ "type": "string",
1460
+ "enum": ["USD", "EUR", "GBP", "INR"],
1461
+ },
1462
+ },
1463
+ },
1464
+ },
1465
+ }
1466
+ }
1467
+ },
1468
+ },
1469
+ }
1470
+ }
1471
+ },
1472
+ }
1473
+
1474
+ # Planted violations (7):
1475
+ # 1 due_date before invoice_date
1476
+ # 2 line_items[0].line_total arithmetic wrong (80 β‰  3 Γ— 25)
1477
+ # 3 billing.subtotal wrong (200 β‰  80+49.99+60 = 189.99)
1478
+ # 4 billing.tax_amount wrong (14.40 β‰  200 Γ— 0.08 = 16.00)
1479
+ # 5 billing.item_count wrong (4 β‰  3)
1480
+ # 6 billing.discount_amount > 0 for trial (25 β‰  0)
1481
+ # 7 billing.total inconsistent (195 β‰  200+14.40-25 = 189.40)
1482
+ payload: Dict[str, Any] = {
1483
+ "invoice_date": "2024-03-15",
1484
+ "due_date": "2024-03-10", # VIOLATION 1 β€” before invoice_date
1485
+ "customer": {
1486
+ "id": 1042,
1487
+ "name": "Acme Corp",
1488
+ "account_type": "trial", # triggers VIOLATION 6
1489
+ },
1490
+ "line_items": [
1491
+ {
1492
+ "description": "Cloud Storage 100 GB/mo",
1493
+ "quantity": 3,
1494
+ "unit_price": 25.00,
1495
+ "line_total": 80.00, # VIOLATION 2 β€” should be 3 Γ— 25.00 = 75.00
1496
+ },
1497
+ {
1498
+ "description": "API Calls 1M/mo",
1499
+ "quantity": 1,
1500
+ "unit_price": 49.99,
1501
+ "line_total": 49.99, # valid
1502
+ },
1503
+ {
1504
+ "description": "Support Package",
1505
+ "quantity": 2,
1506
+ "unit_price": 30.00,
1507
+ "line_total": 60.00, # valid
1508
+ },
1509
+ ],
1510
+ "billing": {
1511
+ "subtotal": 200.00, # VIOLATION 3 β€” sum of line_totals = 189.99
1512
+ "tax_rate": 0.08,
1513
+ "tax_amount": 14.40, # VIOLATION 4 β€” should be 200.00 Γ— 0.08 = 16.00
1514
+ "discount_amount": 25.00, # VIOLATION 6 β€” trial account; must be 0
1515
+ "total": 195.00, # VIOLATION 7 β€” should be 200+14.40-25 = 189.40
1516
+ "item_count": 4, # VIOLATION 5 β€” 3 line_items, not 4
1517
+ "currency": "USD",
1518
+ },
1519
+ }
1520
+
1521
+ violations = [
1522
+ PlantedViolation(
1523
+ field_path="due_date",
1524
+ violation_type="cross_field_constraint",
1525
+ description=(
1526
+ "due_date '2024-03-10' is before invoice_date '2024-03-15'; "
1527
+ "due_date must be strictly after invoice_date."
1528
+ ),
1529
+ expected_value="date after 2024-03-15",
1530
+ actual_value="2024-03-10",
1531
+ ),
1532
+ PlantedViolation(
1533
+ field_path="line_items[0].line_total",
1534
+ violation_type="cross_field_constraint",
1535
+ description=(
1536
+ "line_items[0].line_total is 80.00 but "
1537
+ "quantity(3) Γ— unit_price(25.00) = 75.00."
1538
+ ),
1539
+ expected_value="75.00",
1540
+ actual_value="80.00",
1541
+ ),
1542
+ PlantedViolation(
1543
+ field_path="billing.subtotal",
1544
+ violation_type="cross_field_constraint",
1545
+ description=(
1546
+ "billing.subtotal is 200.00 but the sum of line_totals "
1547
+ "(80.00 + 49.99 + 60.00) = 189.99."
1548
+ ),
1549
+ expected_value="189.99",
1550
+ actual_value="200.00",
1551
+ ),
1552
+ PlantedViolation(
1553
+ field_path="billing.tax_amount",
1554
+ violation_type="cross_field_constraint",
1555
+ description=(
1556
+ "billing.tax_amount is 14.40 but "
1557
+ "billing.subtotal(200.00) Γ— tax_rate(0.08) = 16.00."
1558
+ ),
1559
+ expected_value="16.00",
1560
+ actual_value="14.40",
1561
+ ),
1562
+ PlantedViolation(
1563
+ field_path="billing.item_count",
1564
+ violation_type="cross_field_constraint",
1565
+ description=(
1566
+ "billing.item_count is 4 but there are 3 elements in line_items."
1567
+ ),
1568
+ expected_value="3",
1569
+ actual_value="4",
1570
+ ),
1571
+ PlantedViolation(
1572
+ field_path="billing.discount_amount",
1573
+ violation_type="cross_field_constraint",
1574
+ description=(
1575
+ "billing.discount_amount is 25.00 but customer.account_type "
1576
+ "is 'trial'; trial accounts must have discount_amount = 0."
1577
+ ),
1578
+ expected_value="0",
1579
+ actual_value="25.00",
1580
+ ),
1581
+ PlantedViolation(
1582
+ field_path="billing.total",
1583
+ violation_type="cross_field_constraint",
1584
+ description=(
1585
+ "billing.total is 195.00 but "
1586
+ "subtotal(200.00) + tax_amount(14.40) - discount_amount(25.00) = 189.40."
1587
+ ),
1588
+ expected_value="189.40",
1589
+ actual_value="195.00",
1590
+ ),
1591
+ ]
1592
+
1593
+ return TaskScenario(
1594
+ task_name="validate_cross_field_constraints",
1595
+ task_description=(
1596
+ "You are given an API spec for POST /invoices and a request payload. "
1597
+ "The spec defines cross-field constraints that cannot be checked by "
1598
+ "standard JSON Schema validation. You must actively compute and "
1599
+ "cross-reference values to find violations. "
1600
+ "Constraints to check: "
1601
+ "(1) due_date must be strictly after invoice_date. "
1602
+ "(2) Each line_items[N].line_total must equal quantity Γ— unit_price. "
1603
+ "(3) billing.subtotal must equal the sum of all line_items[N].line_total. "
1604
+ "(4) billing.tax_amount must equal billing.subtotal Γ— billing.tax_rate. "
1605
+ "(5) billing.total must equal billing.subtotal + billing.tax_amount - billing.discount_amount. "
1606
+ "(6) billing.item_count must equal the number of elements in line_items. "
1607
+ "(7) If customer.account_type is 'trial', billing.discount_amount must be 0. "
1608
+ "Use violation_type='cross_field_constraint' for these. "
1609
+ "Report one violation per step. Submit field_path='DONE' when finished."
1610
+ ),
1611
+ api_spec=api_spec,
1612
+ payload=payload,
1613
+ violations=violations,
1614
+ max_steps=18,
1615
+ )
1616
+
1617
+
1618
+ # ── Registry ──────────────────────────────────────────────────────────────
1619
+
1620
+ TASK_GENERATORS = {
1621
+ "find_type_mismatches": generate_easy_scenario,
1622
+ "validate_nested_objects": generate_medium_scenario,
1623
+ "detect_breaking_changes": generate_hard_scenario,
1624
+ "validate_response_schema": generate_format_validation_scenario,
1625
+ "validate_cross_field_constraints": generate_cross_field_scenario,
1626
+ }
1627
+
1628
+ AVAILABLE_TASKS = list(TASK_GENERATORS.keys())
1629
+
1630
+
1631
+ def generate_scenario_for_task(
1632
+ task_name: str, seed: Optional[int] = None
1633
+ ) -> TaskScenario:
1634
+ """Return a ``TaskScenario`` for the requested task.
1635
+
1636
+ Parameters
1637
+ ----------
1638
+ task_name:
1639
+ One of the keys in ``AVAILABLE_TASKS``.
1640
+ seed:
1641
+ Optional integer seed for deterministic randomisation.
1642
+ seed=None β†’ canonical fixed scenario (backward-compatible).
1643
+ seed=int β†’ reproducible randomised variant.
1644
+
1645
+ Raises
1646
+ ------
1647
+ ValueError
1648
+ If *task_name* is not recognised.
1649
+ """
1650
+ generator = TASK_GENERATORS.get(task_name)
1651
+ if generator is None:
1652
+ raise ValueError(
1653
+ f"Unknown task '{task_name}'. Available: {AVAILABLE_TASKS}"
1654
+ )
1655
+ return generator(seed=seed)
tests/__init__.py ADDED
File without changes
tests/test_environment.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the API Contract Validator Environment.
3
+
4
+ Run from the api_contract_validator/ directory:
5
+ pytest tests/ -v
6
+ """
7
+
8
+ import sys
9
+ import os
10
+
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ import pytest
14
+ from server.environment import ValidatorEnvironment
15
+ from server.spec_generator import generate_scenario_for_task, AVAILABLE_TASKS
16
+ from models import ValidatorAction
17
+
18
+
19
+ @pytest.fixture
20
+ def env():
21
+ """Fresh environment for each test."""
22
+ return ValidatorEnvironment()
23
+
24
+
25
+ # ── Task structure ─────────────────────────────────────────────────────────
26
+
27
+
28
+ def test_five_tasks_registered():
29
+ assert len(AVAILABLE_TASKS) == 5
30
+ expected = {
31
+ "find_type_mismatches",
32
+ "validate_nested_objects",
33
+ "detect_breaking_changes",
34
+ "validate_response_schema",
35
+ "validate_cross_field_constraints",
36
+ }
37
+ assert set(AVAILABLE_TASKS) == expected
38
+
39
+
40
+ def test_all_tasks_have_violations():
41
+ for task_name in AVAILABLE_TASKS:
42
+ scenario = generate_scenario_for_task(task_name)
43
+ assert len(scenario.violations) >= 4, (
44
+ f"{task_name} has only {len(scenario.violations)} violations"
45
+ )
46
+ assert scenario.max_steps >= len(scenario.violations), (
47
+ f"{task_name}: max_steps({scenario.max_steps}) < violations({len(scenario.violations)})"
48
+ )
49
+
50
+
51
+ # ── Reset behaviour ────────────────────────────────────────────────────────
52
+
53
+
54
+ def test_all_tasks_reset_cleanly(env):
55
+ for task_name in AVAILABLE_TASKS:
56
+ obs = env.reset(task_name=task_name)
57
+ assert obs.task_name == task_name
58
+ assert obs.done is False
59
+ assert obs.reward == 0.0
60
+ assert obs.violations_found == []
61
+ assert obs.violations_remaining > 0
62
+
63
+
64
+ # ── Correct violation reward ───────────────────────────────────────────────
65
+
66
+
67
+ def test_correct_violation_gives_plus_one(env):
68
+ scenario = generate_scenario_for_task("find_type_mismatches")
69
+ env.reset(task_name="find_type_mismatches")
70
+
71
+ first = scenario.violations[0]
72
+ action = ValidatorAction(
73
+ field_path=first.field_path,
74
+ violation_type=first.violation_type,
75
+ description="test",
76
+ )
77
+ result = env.step(action)
78
+ assert result.reward == 1.0
79
+ assert len(result.violations_found) == 1
80
+
81
+
82
+ # ── False positive penalty ─────────────────────────────────────────────────
83
+
84
+
85
+ def test_false_positive_gives_negative_reward(env):
86
+ env.reset(task_name="find_type_mismatches")
87
+ action = ValidatorAction(
88
+ field_path="nonexistent_field_xyz_abc",
89
+ violation_type="type_mismatch",
90
+ description="fabricated",
91
+ )
92
+ result = env.step(action)
93
+ assert result.reward == pytest.approx(-0.3)
94
+
95
+
96
+ # ── Duplicate penalty ──────────────────────────────────────────────────────
97
+
98
+
99
+ def test_duplicate_gives_small_penalty(env):
100
+ scenario = generate_scenario_for_task("find_type_mismatches")
101
+ env.reset(task_name="find_type_mismatches")
102
+
103
+ first = scenario.violations[0]
104
+ action = ValidatorAction(
105
+ field_path=first.field_path,
106
+ violation_type=first.violation_type,
107
+ description="test",
108
+ )
109
+ result1 = env.step(action)
110
+ assert result1.reward == 1.0
111
+
112
+ result2 = env.step(action) # duplicate
113
+ assert result2.reward == pytest.approx(-0.1)
114
+
115
+
116
+ # ── DONE signal ────────────────────────────────────────────────────────────
117
+
118
+
119
+ def test_done_signal_ends_episode(env):
120
+ env.reset(task_name="find_type_mismatches")
121
+ action = ValidatorAction(field_path="DONE", violation_type="", description="")
122
+ result = env.step(action)
123
+ assert result.done is True
124
+ assert result.reward >= 0.0
125
+
126
+
127
+ # ── HINT mechanic ──────────────────────────────────────────────────────────
128
+
129
+
130
+ def test_hint_costs_half_point(env):
131
+ env.reset(task_name="find_type_mismatches")
132
+ action = ValidatorAction(field_path="HINT", violation_type="", description="")
133
+ result = env.step(action)
134
+ assert result.reward == pytest.approx(-0.5)
135
+ assert "Hint:" in result.feedback
136
+ assert result.done is False
137
+
138
+
139
+ # ── Proximity reward ────────────────────────────────────���──────────────────
140
+
141
+
142
+ def test_proximity_reward_for_correct_path_wrong_type(env):
143
+ scenario = generate_scenario_for_task("find_type_mismatches")
144
+ env.reset(task_name="find_type_mismatches")
145
+
146
+ first = scenario.violations[0]
147
+ action = ValidatorAction(
148
+ field_path=first.field_path,
149
+ violation_type="extra_field", # wrong type on purpose
150
+ description="proximity test",
151
+ )
152
+ result = env.step(action)
153
+ assert result.reward == pytest.approx(0.3)
154
+
155
+
156
+ # ── Seed reproducibility ───────────────────────────────────────────────────
157
+
158
+
159
+ def test_seed_gives_same_scenario():
160
+ for task_name in AVAILABLE_TASKS:
161
+ s1 = generate_scenario_for_task(task_name, seed=42)
162
+ s2 = generate_scenario_for_task(task_name, seed=42)
163
+ assert [v.field_path for v in s1.violations] == [
164
+ v.field_path for v in s2.violations
165
+ ], f"{task_name}: seed=42 gave different results across calls"
166
+
167
+
168
+ def test_different_seeds_give_different_easy_scenarios():
169
+ """Easy task pool should vary with different seeds."""
170
+ paths_by_seed = set()
171
+ for seed in range(8):
172
+ s = generate_scenario_for_task("find_type_mismatches", seed=seed)
173
+ key = tuple(sorted(v.field_path for v in s.violations))
174
+ paths_by_seed.add(key)
175
+ assert len(paths_by_seed) > 1, "Different seeds produced identical scenarios"
176
+
177
+
178
+ # ── Cross-field task ───────────────────────────────────────────────────────
179
+
180
+
181
+ def test_cross_field_task_has_seven_violations():
182
+ scenario = generate_scenario_for_task("validate_cross_field_constraints")
183
+ assert len(scenario.violations) == 7
184
+
185
+
186
+ def test_cross_field_violations_use_correct_type():
187
+ scenario = generate_scenario_for_task("validate_cross_field_constraints")
188
+ for v in scenario.violations:
189
+ assert v.violation_type == "cross_field_constraint", (
190
+ f"Expected cross_field_constraint, got {v.violation_type} for {v.field_path}"
191
+ )
uv.lock ADDED
The diff for this file is too large to render. See raw diff