printf-sourav commited on
Commit
276a6a0
·
1 Parent(s): bacfc37

AI completed

Browse files
Files changed (44) hide show
  1. Dockerfile +12 -0
  2. README.md +217 -1
  3. artifacts/metrics.json +7 -0
  4. artifacts/reward_curve.csv +2 -0
  5. artifacts/success_rate.csv +2 -0
  6. env/__init__.py +3 -0
  7. env/__pycache__/__init__.cpython-312.pyc +0 -0
  8. env/__pycache__/anti_hacking.cpython-312.pyc +0 -0
  9. env/__pycache__/hidden_tests.cpython-312.pyc +0 -0
  10. env/__pycache__/rewards.cpython-312.pyc +0 -0
  11. env/anti_hacking.py +169 -0
  12. env/graders/__init__.py +4 -0
  13. env/graders/__pycache__/__init__.cpython-312.pyc +0 -0
  14. env/graders/__pycache__/deterministic.cpython-312.pyc +0 -0
  15. env/graders/__pycache__/llm_judge.cpython-312.pyc +0 -0
  16. env/graders/deterministic.py +189 -0
  17. env/graders/llm_judge.py +112 -0
  18. env/hidden_tests.py +72 -0
  19. env/rewards.py +171 -0
  20. inference.py +572 -0
  21. inference/__init__.py +4 -0
  22. inference/__pycache__/__init__.cpython-312.pyc +0 -0
  23. inference/__pycache__/metrics.cpython-312.pyc +0 -0
  24. inference/__pycache__/model_wrapper.cpython-312.pyc +0 -0
  25. inference/__pycache__/prompts.cpython-312.pyc +0 -0
  26. inference/__pycache__/visualize.cpython-312.pyc +0 -0
  27. inference/metrics.py +56 -0
  28. inference/model_wrapper.py +109 -0
  29. inference/prompts.py +80 -0
  30. inference/visualize.py +42 -0
  31. openenv.yaml +69 -0
  32. pyproject.toml +22 -0
  33. requirements.txt +7 -0
  34. server/__init__.py +0 -0
  35. server/app.py +20 -0
  36. tests/__init__.py +0 -0
  37. tests/__pycache__/test_day2_engine.cpython-312.pyc +0 -0
  38. tests/__pycache__/test_inference.cpython-312.pyc +0 -0
  39. tests/__pycache__/test_judge.cpython-312.pyc +0 -0
  40. tests/test_day2_engine.py +198 -0
  41. tests/test_inference.py +54 -0
  42. tests/test_judge.py +51 -0
  43. uv.lock +0 -0
  44. validate-submission.sh +163 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt ./
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ ENV OFFLINE_INFERENCE=1
11
+
12
+ CMD ["python", "inference.py", "--max-steps", "8", "--policy-mode", "imp", "--trajectories", "4", "--force-local-env"]
README.md CHANGED
@@ -1 +1,217 @@
1
- # RL_Mini_Project
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CI/CD Pipeline Debugger Environment (OpenEnv)
2
+
3
+ ## 1. Project Goal
4
+
5
+ This repository implements an AI training and evaluation environment where an agent learns to debug broken CI/CD pipelines automatically.
6
+
7
+ The environment targets real-world DevOps failure patterns, including:
8
+
9
+ - YAML syntax and structure issues
10
+ - Incorrect build/test commands (for example, npm tset -> npm test)
11
+ - Dependency and setup failures
12
+ - Multi-stage pipeline execution errors
13
+
14
+ This is designed as an RL-style interaction loop:
15
+
16
+ Observe -> Think -> Act -> Get Reward -> Repeat
17
+
18
+ ## 2. Why This Matters
19
+
20
+ CI/CD failures are common, repetitive, and often multi-step to resolve. This project turns that workflow into a structured learning environment where agents:
21
+
22
+ - Read failure context
23
+ - Reason about root causes
24
+ - Propose and apply fixes
25
+ - Get shaped rewards for robust behavior
26
+
27
+ ## 3. System Architecture
28
+
29
+ High-level flow:
30
+
31
+ Agent (LLM) -> Action -> Environment.step() -> Reward/Evaluation -> Next step
32
+
33
+ Core integration path:
34
+
35
+ Model -> Action -> Environment.step() -> RewardCalculator
36
+
37
+ RewardCalculator integrates:
38
+
39
+ - DeterministicGrader
40
+ - LLMJudge
41
+ - HiddenTestRunner
42
+ - AntiHackingDetector
43
+
44
+ ## 4. Core Modules
45
+
46
+ ### 4.1 Quality Judge
47
+
48
+ - File: env/graders/llm_judge.py
49
+ - Purpose: quality-aware scoring of fixes
50
+ - Output keys: correctness, minimalism, quality (all in [0,1])
51
+ - Guarantees:
52
+ - strict JSON parsing attempt
53
+ - robust fallback parsing for messy output
54
+ - no-crash behavior (safe zero scores on failure)
55
+
56
+ ### 4.2 Deterministic Grader
57
+
58
+ - File: env/graders/deterministic.py
59
+ - Purpose: reproducible correctness scoring (0-1)
60
+ - Checks:
61
+ - YAML validity
62
+ - command and fix correctness
63
+ - similarity and issue resolution
64
+ - Rules:
65
+ - deterministic only
66
+ - same input, same score
67
+
68
+ ### 4.3 Anti-Hacking Detector
69
+
70
+ - File: env/anti_hacking.py
71
+ - Purpose: detect reward-hacking and shortcut behavior
72
+ - Penalty detectors:
73
+ - stage skipping (if: false, when: never)
74
+ - fake success (echo tests passed, unsafe exit 0 patterns)
75
+ - pipeline breakage between versions
76
+ - excessive edits
77
+ - timeout abuse via too many steps
78
+
79
+ ### 4.4 Hidden Tests
80
+
81
+ - File: env/hidden_tests.py
82
+ - Purpose: test fix robustness, not just exact-match overfitting
83
+ - Method:
84
+ - deterministic variant generation (OS, versions, env shifts)
85
+ - evaluate pass rate across variants
86
+
87
+ ### 4.5 Reward Shaping
88
+
89
+ - File: env/rewards.py
90
+ - Purpose: step-level learning signal
91
+ - Components:
92
+ - progress rewards (logs, analysis, fix proposal)
93
+ - execution rewards (pipeline run, tests pass)
94
+ - quality rewards (deterministic + hidden tests + LLM judge)
95
+ - anti-hacking penalties
96
+
97
+ ## 5. Inference and Evaluation
98
+
99
+ ### 5.1 Prompt and Model Layers
100
+
101
+ - inference/prompts.py: stable prompt templates and fallback action heuristics
102
+ - inference/model_wrapper.py: OpenAI-client action generation, candidate generation, and safe fallback
103
+
104
+ ### 5.2 Metrics and Artifacts
105
+
106
+ - inference/metrics.py: reward, success-rate, and failure reason tracking
107
+ - inference/visualize.py: reward curve and metrics artifact export
108
+
109
+ ### 5.3 Submission-Critical Runtime
110
+
111
+ - File: inference.py (root)
112
+ - Responsibilities:
113
+ - initialize model and environment
114
+ - run step loop
115
+ - calculate rewards
116
+ - emit strict stdout contract
117
+ - always emit END line
118
+
119
+ Required output format:
120
+
121
+ - [START] task=... env=... model=...
122
+ - [STEP] step=<n> action=... reward=0.00 done=<true|false> error=<msg|null>
123
+ - [END] success=<true|false> steps=<n> rewards=<r1,r2,...>
124
+
125
+ Rules enforced:
126
+
127
+ - single-line logs only
128
+ - reward values with 2 decimals
129
+ - lowercase booleans
130
+ - no extra runtime log noise
131
+
132
+ ## 6. Task Coverage
133
+
134
+ The project includes 13 CI-fix tasks spanning:
135
+
136
+ - easy: syntax and typo fixes
137
+ - medium: dependency/env/cache/permissions issues
138
+ - hard: matrix logic, conditional flow, orchestration-level failures
139
+
140
+ ## 7. Setup
141
+
142
+ ```bash
143
+ python3 -m venv .venv
144
+ source .venv/bin/activate
145
+ pip install -r requirements.txt
146
+ ```
147
+
148
+ Environment variables:
149
+
150
+ ```bash
151
+ export API_BASE_URL="https://router.huggingface.co/v1"
152
+ export MODEL_NAME="Qwen/Qwen2.5-72B-Instruct"
153
+ export HF_TOKEN="<your_token>"
154
+ export LOCAL_IMAGE_NAME="<your_env_image_name>"
155
+ ```
156
+
157
+ ## 8. Run Inference
158
+
159
+ Offline/local mode:
160
+
161
+ ```bash
162
+ python inference.py --offline --force-local-env --max-steps 8 --policy-mode imp --trajectories 4
163
+ ```
164
+
165
+ Model-backed mode:
166
+
167
+ ```bash
168
+ python inference.py --max-steps 8 --policy-mode imp --trajectories 4
169
+ ```
170
+
171
+ Policy modes:
172
+
173
+ - sft: deterministic heuristic policy
174
+ - direct: single model action per step
175
+ - imp: multi-candidate generation and ranking
176
+
177
+ ## 9. Tests
178
+
179
+ Run all tests:
180
+
181
+ ```bash
182
+ python -m unittest discover -s tests -v
183
+ ```
184
+
185
+ Coverage includes:
186
+
187
+ - LLM judge
188
+ - deterministic grader
189
+ - anti-hacking detectors
190
+ - hidden tests
191
+ - reward system
192
+ - end-to-end inference output format
193
+
194
+ ## 10. Validation and Submission
195
+
196
+ OpenEnv validation:
197
+
198
+ ```bash
199
+ openenv validate
200
+ ```
201
+
202
+ Pre-submission script:
203
+
204
+ ```bash
205
+ ./validate-submission.sh <your_hf_space_url>
206
+ ```
207
+
208
+ Docker run:
209
+
210
+ ```bash
211
+ docker build -t cicd-debugger-env .
212
+ docker run --rm -e OFFLINE_INFERENCE=1 cicd-debugger-env
213
+ ```
214
+
215
+ ## 11. One-line Presentation Summary
216
+
217
+ We built an OpenEnv-compliant reinforcement learning environment where AI agents learn to debug real CI/CD pipelines using multi-step reasoning, hybrid grading, anti-hacking safeguards, and robust reward shaping.
artifacts/metrics.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "average_reward": 1.84,
3
+ "failure_reasons": {},
4
+ "steps": 1,
5
+ "success_rate": 1.0,
6
+ "total_reward": 1.84
7
+ }
artifacts/reward_curve.csv ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ step,reward
2
+ 1,1.8400
artifacts/success_rate.csv ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ episode,success,success_rate
2
+ 1,1,1.0000
env/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from env.rewards import RewardCalculator
2
+
3
+ __all__ = ["RewardCalculator"]
env/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (236 Bytes). View file
 
env/__pycache__/anti_hacking.cpython-312.pyc ADDED
Binary file (7.93 kB). View file
 
env/__pycache__/hidden_tests.cpython-312.pyc ADDED
Binary file (3.4 kB). View file
 
env/__pycache__/rewards.cpython-312.pyc ADDED
Binary file (7.93 kB). View file
 
env/anti_hacking.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+
9
+ class AntiHackingDetector:
10
+ """Detects shortcut behaviors that can game CI-fix rewards."""
11
+
12
+ STAGE_SKIP_PATTERNS = (
13
+ r"\bif\s*:\s*false\b",
14
+ r"\bwhen\s*:\s*never\b",
15
+ r"\bon\s*:\s*\[\s*\]\b",
16
+ r"\bon\s*:\s*{}",
17
+ r"\ballow_failure\s*:\s*true\b",
18
+ )
19
+
20
+ FAKE_SUCCESS_PATTERNS = (
21
+ r"echo\s+[\"']?tests\s+passed[\"']?",
22
+ r"echo\s+[\"']?success[\"']?",
23
+ r"\bexit\s+0\b",
24
+ r"\btrue\b\s*#?\s*force",
25
+ )
26
+
27
+ BROKEN_COMMAND_PATTERNS = (
28
+ r"\bnpm\s+tset\b",
29
+ r"\bpyhton\b",
30
+ r"\bpip\s+isntall\b",
31
+ r"\bgo\s+tset\b",
32
+ )
33
+
34
+ def penalty_stage_skipping(self, config_text: str) -> float:
35
+ hits = self._count_hits(config_text, self.STAGE_SKIP_PATTERNS)
36
+ if hits == 0:
37
+ return 0.0
38
+ return -min(0.20 * hits, 0.70)
39
+
40
+ def penalty_fake_success(self, config_text: str) -> float:
41
+ hits = self._count_hits(config_text, self.FAKE_SUCCESS_PATTERNS)
42
+ if hits == 0:
43
+ return 0.0
44
+
45
+ normalized = (config_text or "").lower()
46
+ has_real_test_cmd = any(token in normalized for token in ("npm test", "pytest", "go test", "mvn test", "yarn test", "pnpm test"))
47
+ base = 0.15 if has_real_test_cmd else 0.25
48
+ return -min(base * hits, 0.70)
49
+
50
+ def penalty_breaking_pipeline(self, previous_config: str, new_config: str) -> float:
51
+ if not previous_config or not new_config:
52
+ return 0.0
53
+
54
+ penalty = 0.0
55
+
56
+ previous_valid = self._is_yaml_valid(previous_config)
57
+ new_valid = self._is_yaml_valid(new_config)
58
+ if previous_valid and not new_valid:
59
+ penalty -= 0.40
60
+
61
+ previous_stages = self._extract_stage_names(previous_config)
62
+ new_stages = self._extract_stage_names(new_config)
63
+ missing_stages = previous_stages - new_stages
64
+ if missing_stages:
65
+ penalty -= min(0.15 * len(missing_stages), 0.45)
66
+
67
+ previous_broken = self._count_hits(previous_config, self.BROKEN_COMMAND_PATTERNS)
68
+ new_broken = self._count_hits(new_config, self.BROKEN_COMMAND_PATTERNS)
69
+ if new_broken > previous_broken:
70
+ penalty -= min(0.10 * (new_broken - previous_broken), 0.30)
71
+
72
+ return max(-1.0, penalty)
73
+
74
+ def penalty_excessive_edits(
75
+ self,
76
+ edit_count: int | dict[str, Any] | None = None,
77
+ changed_files_count: int = 0,
78
+ changed_lines_count: int = 0,
79
+ ) -> float:
80
+ if isinstance(edit_count, dict):
81
+ changed_files_count = int(edit_count.get("changed_files_count", changed_files_count) or 0)
82
+ changed_lines_count = int(edit_count.get("changed_lines_count", changed_lines_count) or 0)
83
+ elif isinstance(edit_count, int):
84
+ changed_lines_count = max(changed_lines_count, int(edit_count))
85
+
86
+ penalty = 0.0
87
+
88
+ if changed_files_count > 5:
89
+ penalty -= 0.15
90
+ if changed_files_count > 10:
91
+ penalty -= 0.25
92
+
93
+ if changed_lines_count > 120:
94
+ penalty -= 0.15
95
+ if changed_lines_count > 300:
96
+ penalty -= 0.25
97
+
98
+ return max(-0.80, penalty)
99
+
100
+ def penalty_timeout_abuse(self, step_count: int) -> float:
101
+ if step_count > 30:
102
+ return -0.80
103
+ if step_count > 20:
104
+ return -0.50
105
+ return 0.0
106
+
107
+ def total_penalty(
108
+ self,
109
+ current_config: str = "",
110
+ previous_config: str = "",
111
+ edit_count: int | dict[str, Any] | None = None,
112
+ changed_files_count: int = 0,
113
+ changed_lines_count: int = 0,
114
+ step_count: int = 0,
115
+ ) -> float:
116
+ total = 0.0
117
+ total += self.penalty_stage_skipping(current_config)
118
+ total += self.penalty_fake_success(current_config)
119
+ total += self.penalty_breaking_pipeline(previous_config, current_config)
120
+ total += self.penalty_excessive_edits(
121
+ edit_count=edit_count,
122
+ changed_files_count=changed_files_count,
123
+ changed_lines_count=changed_lines_count,
124
+ )
125
+ total += self.penalty_timeout_abuse(step_count)
126
+
127
+ return round(total, 4)
128
+
129
+ def _count_hits(self, text: str, patterns: tuple[str, ...]) -> int:
130
+ text = text or ""
131
+ return sum(1 for pattern in patterns if re.search(pattern, text, flags=re.IGNORECASE))
132
+
133
+ def _is_yaml_valid(self, config_text: str) -> bool:
134
+ if not (config_text or "").strip():
135
+ return False
136
+ try:
137
+ yaml.safe_load(config_text)
138
+ return True
139
+ except yaml.YAMLError:
140
+ return False
141
+
142
+ def _extract_stage_names(self, config_text: str) -> set[str]:
143
+ try:
144
+ parsed = yaml.safe_load(config_text)
145
+ except yaml.YAMLError:
146
+ return set()
147
+
148
+ if parsed is None:
149
+ return set()
150
+
151
+ stages: set[str] = set()
152
+ self._walk_for_stages(parsed, stages)
153
+ return stages
154
+
155
+ def _walk_for_stages(self, node: Any, stages: set[str]) -> None:
156
+ if isinstance(node, dict):
157
+ for key, value in node.items():
158
+ key_name = str(key).lower()
159
+ if key_name in {"stages", "jobs", "job"}:
160
+ if isinstance(value, dict):
161
+ for stage_name in value.keys():
162
+ stages.add(str(stage_name))
163
+ elif isinstance(value, list):
164
+ for stage_name in value:
165
+ stages.add(str(stage_name))
166
+ self._walk_for_stages(value, stages)
167
+ elif isinstance(node, list):
168
+ for item in node:
169
+ self._walk_for_stages(item, stages)
env/graders/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from env.graders.deterministic import DeterministicGrader
2
+ from env.graders.llm_judge import LLMJudge
3
+
4
+ __all__ = ["DeterministicGrader", "LLMJudge"]
env/graders/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (325 Bytes). View file
 
env/graders/__pycache__/deterministic.cpython-312.pyc ADDED
Binary file (9.45 kB). View file
 
env/graders/__pycache__/llm_judge.cpython-312.pyc ADDED
Binary file (5.58 kB). View file
 
env/graders/deterministic.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from difflib import SequenceMatcher
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ class DeterministicGrader:
11
+ """Deterministic correctness scoring for CI/CD config fixes."""
12
+
13
+ COMMAND_KEYS = {
14
+ "script",
15
+ "scripts",
16
+ "run",
17
+ "command",
18
+ "commands",
19
+ "steps",
20
+ "before_script",
21
+ "after_script",
22
+ }
23
+
24
+ BROKEN_COMMAND_PATTERNS = (
25
+ r"\bnpm\s+tset\b",
26
+ r"\bpyhton\b",
27
+ r"\bpip\s+isntall\b",
28
+ r"\bgo\s+tset\b",
29
+ )
30
+
31
+ def grade(self, current_config: str, expected_config: str, metadata: dict[str, Any] | None = None) -> float:
32
+ metadata = metadata or {}
33
+ current_config = current_config or ""
34
+ expected_config = expected_config or ""
35
+
36
+ syntax_score = self._syntax_score(current_config)
37
+ functional_score = self._functional_score(current_config, expected_config, metadata)
38
+ similarity_score = self._similarity_score(current_config, expected_config)
39
+
40
+ total = (0.20 * syntax_score) + (0.60 * functional_score) + (0.20 * similarity_score)
41
+
42
+ if syntax_score == 0.0:
43
+ total = min(total, 0.30)
44
+
45
+ return round(self._clamp_01(total), 4)
46
+
47
+ def _syntax_score(self, config_text: str) -> float:
48
+ if not (config_text or "").strip():
49
+ return 0.0
50
+
51
+ try:
52
+ yaml.safe_load(config_text)
53
+ return 1.0
54
+ except yaml.YAMLError:
55
+ return 0.0
56
+
57
+ def _functional_score(self, current_config: str, expected_config: str, metadata: dict[str, Any]) -> float:
58
+ expected_commands = self._extract_commands(expected_config)
59
+ current_commands = self._extract_commands(current_config)
60
+
61
+ if expected_commands:
62
+ matched = 0
63
+ for expected in expected_commands:
64
+ if any(self._commands_match(expected, current) for current in current_commands):
65
+ matched += 1
66
+ command_score = matched / len(expected_commands)
67
+ else:
68
+ command_score = self._similarity_score(current_config, expected_config)
69
+
70
+ issue_score = self._issue_resolution_score(current_config, metadata)
71
+ broken_penalty = 0.35 if self._has_known_broken_command(current_config) else 0.0
72
+
73
+ combined = (0.80 * command_score) + (0.20 * issue_score) - broken_penalty
74
+ return self._clamp_01(combined)
75
+
76
+ def _issue_resolution_score(self, current_config: str, metadata: dict[str, Any]) -> float:
77
+ broken_token = self._normalize_text(str(metadata.get("broken_token", "")))
78
+ fixed_token = self._normalize_text(str(metadata.get("fixed_token", "")))
79
+ current_normalized = self._normalize_text(current_config)
80
+
81
+ if not broken_token and not fixed_token:
82
+ return 1.0
83
+
84
+ if broken_token and broken_token in current_normalized:
85
+ return 0.0
86
+
87
+ if fixed_token and fixed_token not in current_normalized:
88
+ return 0.0
89
+
90
+ return 1.0
91
+
92
+ def _extract_commands(self, config_text: str) -> list[str]:
93
+ commands: list[str] = []
94
+
95
+ try:
96
+ parsed = yaml.safe_load(config_text)
97
+ except yaml.YAMLError:
98
+ parsed = None
99
+
100
+ if parsed is not None:
101
+ self._walk_yaml(parsed, commands)
102
+
103
+ if not commands:
104
+ commands.extend(self._extract_commands_from_text(config_text))
105
+
106
+ deduped: list[str] = []
107
+ seen: set[str] = set()
108
+ for command in commands:
109
+ normalized = self._normalize_text(command)
110
+ if normalized and normalized not in seen:
111
+ seen.add(normalized)
112
+ deduped.append(normalized)
113
+
114
+ return deduped
115
+
116
+ def _walk_yaml(self, node: Any, commands: list[str]) -> None:
117
+ if isinstance(node, dict):
118
+ for key, value in node.items():
119
+ key_name = str(key).lower()
120
+ if key_name in self.COMMAND_KEYS:
121
+ commands.extend(self._extract_string_values(value))
122
+ self._walk_yaml(value, commands)
123
+ elif isinstance(node, list):
124
+ for item in node:
125
+ self._walk_yaml(item, commands)
126
+
127
+ def _extract_string_values(self, value: Any) -> list[str]:
128
+ if isinstance(value, str):
129
+ return [value]
130
+ if isinstance(value, list):
131
+ return [item for item in value if isinstance(item, str)]
132
+ if isinstance(value, dict):
133
+ output: list[str] = []
134
+ for nested in value.values():
135
+ output.extend(self._extract_string_values(nested))
136
+ return output
137
+ return []
138
+
139
+ def _extract_commands_from_text(self, config_text: str) -> list[str]:
140
+ commands: list[str] = []
141
+
142
+ for raw_line in (config_text or "").splitlines():
143
+ line = raw_line.strip()
144
+ if not line or line.startswith("#"):
145
+ continue
146
+
147
+ if ":" in line and not line.startswith("-") and line.endswith(":"):
148
+ continue
149
+
150
+ line = line.lstrip("-").strip()
151
+ if any(token in line.lower() for token in ("npm", "pytest", "python", "yarn", "pnpm", "go test", "mvn test")):
152
+ commands.append(line)
153
+
154
+ return commands
155
+
156
+ def _has_known_broken_command(self, config_text: str) -> bool:
157
+ return any(re.search(pattern, config_text or "", flags=re.IGNORECASE) for pattern in self.BROKEN_COMMAND_PATTERNS)
158
+
159
+ def _commands_match(self, expected: str, current: str) -> bool:
160
+ expected_normalized = self._normalize_text(expected)
161
+ current_normalized = self._normalize_text(current)
162
+
163
+ if expected_normalized == current_normalized:
164
+ return True
165
+
166
+ if expected_normalized in current_normalized:
167
+ return True
168
+
169
+ if current_normalized in expected_normalized and len(current_normalized) > 6:
170
+ return True
171
+
172
+ return False
173
+
174
+ def _similarity_score(self, current_config: str, expected_config: str) -> float:
175
+ left = self._normalize_text(current_config)
176
+ right = self._normalize_text(expected_config)
177
+
178
+ if not left and not right:
179
+ return 1.0
180
+ if not left or not right:
181
+ return 0.0
182
+
183
+ return self._clamp_01(SequenceMatcher(None, left, right).ratio())
184
+
185
+ def _normalize_text(self, value: str) -> str:
186
+ return re.sub(r"\s+", " ", (value or "")).strip().lower()
187
+
188
+ def _clamp_01(self, value: float) -> float:
189
+ return max(0.0, min(1.0, float(value)))
env/graders/llm_judge.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Any
6
+
7
+
8
+ class LLMJudge:
9
+ """Scores qualitative fix quality while remaining robust to bad model output."""
10
+
11
+ def __init__(self, model: Any):
12
+ self.model = model
13
+
14
+ def build_prompt(self, original_config: str, fixed_config: str, error_message: str) -> str:
15
+ return (
16
+ "You are a CI/CD fix quality judge.\n"
17
+ "Return strict JSON with keys correctness, minimalism, quality in [0,1].\n"
18
+ "No prose.\n\n"
19
+ f"Original config:\n{original_config}\n\n"
20
+ f"Fixed config:\n{fixed_config}\n\n"
21
+ f"Error message:\n{error_message}\n"
22
+ )
23
+
24
+ def evaluate_fix(self, original_config: str, fixed_config: str, error_message: str) -> dict[str, float]:
25
+ default = {
26
+ "correctness": 0.0,
27
+ "minimalism": 0.0,
28
+ "quality": 0.0,
29
+ }
30
+
31
+ if self.model is None:
32
+ return default
33
+
34
+ prompt = self.build_prompt(original_config or "", fixed_config or "", error_message or "")
35
+
36
+ try:
37
+ raw_output = self.model(prompt, max_length=300)
38
+ text = self._extract_text(raw_output)
39
+ except Exception:
40
+ return default
41
+
42
+ if not text.strip():
43
+ return default
44
+
45
+ parsed = self._parse_json_with_fallback(text)
46
+ if parsed is None:
47
+ parsed = self._parse_regex_scores(text)
48
+
49
+ return {
50
+ "correctness": self._clamp(parsed.get("correctness", 0.0) if parsed else 0.0),
51
+ "minimalism": self._clamp(parsed.get("minimalism", 0.0) if parsed else 0.0),
52
+ "quality": self._clamp(parsed.get("quality", 0.0) if parsed else 0.0),
53
+ }
54
+
55
+ def _extract_text(self, raw_output: Any) -> str:
56
+ if isinstance(raw_output, str):
57
+ return raw_output
58
+
59
+ if isinstance(raw_output, list) and raw_output:
60
+ first = raw_output[0]
61
+ if isinstance(first, dict):
62
+ for key in ("generated_text", "text", "content"):
63
+ if key in first and first[key] is not None:
64
+ return str(first[key])
65
+ return str(first)
66
+
67
+ if isinstance(raw_output, dict):
68
+ for key in ("generated_text", "text", "content"):
69
+ if key in raw_output and raw_output[key] is not None:
70
+ return str(raw_output[key])
71
+
72
+ return str(raw_output)
73
+
74
+ def _parse_json_with_fallback(self, text: str) -> dict[str, float] | None:
75
+ decoder = json.JSONDecoder()
76
+ for idx, char in enumerate(text):
77
+ if char != "{":
78
+ continue
79
+ try:
80
+ obj, _ = decoder.raw_decode(text[idx:])
81
+ except json.JSONDecodeError:
82
+ continue
83
+ if isinstance(obj, dict):
84
+ return self._normalize_partial_scores(obj)
85
+ return None
86
+
87
+ def _parse_regex_scores(self, text: str) -> dict[str, float]:
88
+ return {
89
+ "correctness": self._extract_score(text, "correctness"),
90
+ "minimalism": self._extract_score(text, "minimalism"),
91
+ "quality": self._extract_score(text, "quality"),
92
+ }
93
+
94
+ def _extract_score(self, text: str, key: str) -> float:
95
+ match = re.search(rf"{key}\s*[:=\-]\s*([0-9]*\.?[0-9]+)", text, flags=re.IGNORECASE)
96
+ if not match:
97
+ return 0.0
98
+ return self._clamp(match.group(1))
99
+
100
+ def _normalize_partial_scores(self, obj: dict[str, Any]) -> dict[str, float]:
101
+ return {
102
+ "correctness": self._clamp(obj.get("correctness", 0.0)),
103
+ "minimalism": self._clamp(obj.get("minimalism", 0.0)),
104
+ "quality": self._clamp(obj.get("quality", 0.0)),
105
+ }
106
+
107
+ def _clamp(self, value: Any) -> float:
108
+ try:
109
+ parsed = float(value)
110
+ except (TypeError, ValueError):
111
+ parsed = 0.0
112
+ return max(0.0, min(1.0, parsed))
env/hidden_tests.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from env.graders.deterministic import DeterministicGrader
6
+
7
+
8
+ class HiddenTestRunner:
9
+ """Evaluates whether a fix generalizes across deterministic CI variants."""
10
+
11
+ def __init__(self, grader: DeterministicGrader | None = None, pass_threshold: float = 0.65):
12
+ self.grader = grader or DeterministicGrader()
13
+ self.pass_threshold = pass_threshold
14
+
15
+ def generate_variants(self, config_text: str) -> list[str]:
16
+ base = config_text or ""
17
+ variants: list[str] = []
18
+
19
+ for replacements in self._variant_replacement_sets():
20
+ variant = self._apply_replacements(base, replacements)
21
+ if variant not in variants:
22
+ variants.append(variant)
23
+
24
+ return variants
25
+
26
+ def evaluate_fix(
27
+ self,
28
+ fixed_config: str,
29
+ task: dict[str, Any] | None = None,
30
+ expected_config: str | None = None,
31
+ metadata: dict[str, Any] | None = None,
32
+ ) -> float:
33
+ fixed_config = fixed_config or ""
34
+ task = task or {}
35
+ metadata = metadata or {}
36
+ expected = expected_config or str(task.get("expected_config", ""))
37
+
38
+ if not fixed_config.strip() or not expected.strip():
39
+ return 0.0
40
+
41
+ total = 0
42
+ passed = 0
43
+
44
+ for replacements in self._variant_replacement_sets():
45
+ fixed_variant = self._apply_replacements(fixed_config, replacements)
46
+ expected_variant = self._apply_replacements(expected, replacements)
47
+ score = self.grader.grade(fixed_variant, expected_variant, metadata)
48
+ total += 1
49
+ if score >= self.pass_threshold:
50
+ passed += 1
51
+
52
+ if total == 0:
53
+ return 0.0
54
+
55
+ return round(passed / total, 4)
56
+
57
+ def _variant_replacement_sets(self) -> list[tuple[tuple[str, str], ...]]:
58
+ return [
59
+ tuple(),
60
+ (("ubuntu-latest", "windows-latest"),),
61
+ (("windows-latest", "ubuntu-latest"),),
62
+ (("node-version: 16", "node-version: 18"),),
63
+ (("node-version: \"16\"", "node-version: \"18\""),),
64
+ (("python-version: \"3.10\"", "python-version: \"3.12\""),),
65
+ (("NODE_ENV=production", "NODE_ENV=development"),),
66
+ ]
67
+
68
+ def _apply_replacements(self, text: str, replacements: tuple[tuple[str, str], ...]) -> str:
69
+ output = text
70
+ for old, new in replacements:
71
+ output = output.replace(old, new)
72
+ return output
env/rewards.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from env.anti_hacking import AntiHackingDetector
6
+ from env.graders.deterministic import DeterministicGrader
7
+ from env.hidden_tests import HiddenTestRunner
8
+
9
+
10
+ class RewardCalculator:
11
+ """Composes progress, execution, quality, and anti-hacking penalties."""
12
+
13
+ ACTION_PROGRESS_REWARDS = {
14
+ "read_logs": 0.03,
15
+ "analyze_error": 0.05,
16
+ "propose_fix": 0.06,
17
+ "edit_config": 0.05,
18
+ "validate_fix": 0.06,
19
+ }
20
+
21
+ QUALITY_WEIGHTS = {
22
+ "deterministic": 0.40,
23
+ "hidden": 0.25,
24
+ "llm": 0.20,
25
+ }
26
+
27
+ def __init__(
28
+ self,
29
+ llm_judge: Any | None = None,
30
+ anti_hacking_detector: AntiHackingDetector | None = None,
31
+ deterministic_grader: DeterministicGrader | None = None,
32
+ hidden_test_runner: HiddenTestRunner | None = None,
33
+ ):
34
+ self.llm_judge = llm_judge
35
+ self.anti_hacking_detector = anti_hacking_detector or AntiHackingDetector()
36
+ self.deterministic_grader = deterministic_grader or DeterministicGrader()
37
+ self.hidden_test_runner = hidden_test_runner or HiddenTestRunner(grader=self.deterministic_grader)
38
+
39
+ def calculate_step_reward(
40
+ self,
41
+ state: dict[str, Any] | None,
42
+ action: str,
43
+ result: dict[str, Any] | None,
44
+ original_config: str | None = None,
45
+ fixed_config: str | None = None,
46
+ error_message: str | None = None,
47
+ expected_config: str | None = None,
48
+ metadata: dict[str, Any] | None = None,
49
+ ) -> float:
50
+ state = state or {}
51
+ result = result or {}
52
+ metadata = metadata or {}
53
+
54
+ current_config = fixed_config or result.get("fixed_config") or result.get("current_config") or ""
55
+ expected_config = expected_config or result.get("expected_config") or state.get("expected_config") or ""
56
+ original_config = original_config or result.get("original_config") or state.get("original_config") or ""
57
+ error_message = error_message or result.get("error") or state.get("error") or ""
58
+
59
+ reward = 0.0
60
+ reward += self._progress_reward(action, result)
61
+ reward += self._execution_reward(result)
62
+ reward += self._quality_reward(
63
+ action=action,
64
+ current_config=current_config,
65
+ expected_config=expected_config,
66
+ original_config=original_config,
67
+ error_message=error_message,
68
+ result=result,
69
+ metadata=metadata,
70
+ )
71
+ reward += self._penalty_reward(state=state, result=result, current_config=current_config)
72
+
73
+ return round(float(reward), 4)
74
+
75
+ def _progress_reward(self, action: str, result: dict[str, Any]) -> float:
76
+ reward = self.ACTION_PROGRESS_REWARDS.get(action, 0.0)
77
+
78
+ if result.get("logs_analyzed"):
79
+ reward += 0.04
80
+ if result.get("error_diagnosed"):
81
+ reward += 0.08
82
+ if result.get("fix_proposed"):
83
+ reward += 0.05
84
+
85
+ return reward
86
+
87
+ def _execution_reward(self, result: dict[str, Any]) -> float:
88
+ reward = 0.0
89
+
90
+ if result.get("pipeline_run"):
91
+ reward += 0.10
92
+ if result.get("tests_passed"):
93
+ reward += 0.20
94
+ if result.get("command_succeeded"):
95
+ reward += 0.06
96
+
97
+ return reward
98
+
99
+ def _quality_reward(
100
+ self,
101
+ action: str,
102
+ current_config: str,
103
+ expected_config: str,
104
+ original_config: str,
105
+ error_message: str,
106
+ result: dict[str, Any],
107
+ metadata: dict[str, Any],
108
+ ) -> float:
109
+ if not current_config or not expected_config:
110
+ return 0.0
111
+
112
+ deterministic_score = result.get("deterministic_score")
113
+ if deterministic_score is None:
114
+ deterministic_score = self.deterministic_grader.grade(current_config, expected_config, metadata)
115
+
116
+ hidden_pass_rate = result.get("hidden_test_pass_rate")
117
+ if hidden_pass_rate is None and action in {"validate_fix", "run_hidden_tests", "submit_fix"}:
118
+ hidden_pass_rate = self.hidden_test_runner.evaluate_fix(
119
+ fixed_config=current_config,
120
+ expected_config=expected_config,
121
+ metadata=metadata,
122
+ )
123
+
124
+ llm_average = 0.0
125
+ judge_scores = result.get("judge_scores")
126
+ if not judge_scores and self.llm_judge and original_config and current_config:
127
+ try:
128
+ judge_scores = self.llm_judge.evaluate_fix(original_config, current_config, error_message)
129
+ except Exception:
130
+ judge_scores = None
131
+
132
+ if isinstance(judge_scores, dict):
133
+ correctness = self._clamp_01(judge_scores.get("correctness", 0.0))
134
+ minimalism = self._clamp_01(judge_scores.get("minimalism", 0.0))
135
+ quality = self._clamp_01(judge_scores.get("quality", 0.0))
136
+ llm_average = (correctness + minimalism + quality) / 3.0
137
+
138
+ quality_reward = 0.0
139
+ quality_reward += self.QUALITY_WEIGHTS["deterministic"] * self._clamp_01(deterministic_score)
140
+ quality_reward += self.QUALITY_WEIGHTS["hidden"] * self._clamp_01(hidden_pass_rate or 0.0)
141
+ quality_reward += self.QUALITY_WEIGHTS["llm"] * self._clamp_01(llm_average)
142
+
143
+ return quality_reward
144
+
145
+ def _penalty_reward(self, state: dict[str, Any], result: dict[str, Any], current_config: str) -> float:
146
+ changed_files_count = int(result.get("changed_files_count", state.get("changed_files_count", 0)) or 0)
147
+ changed_lines_count = int(result.get("changed_lines_count", state.get("changed_lines_count", 0)) or 0)
148
+ edit_count = result.get("edit_count", state.get("edit_count", 0))
149
+ step_count = int(state.get("step_count", 0) or 0)
150
+ previous_config = result.get("previous_config") or state.get("previous_config") or ""
151
+
152
+ penalty = self.anti_hacking_detector.total_penalty(
153
+ current_config=current_config,
154
+ previous_config=previous_config,
155
+ edit_count=edit_count,
156
+ changed_files_count=changed_files_count,
157
+ changed_lines_count=changed_lines_count,
158
+ step_count=step_count,
159
+ )
160
+
161
+ if result.get("hacking_attempt"):
162
+ penalty -= 0.30
163
+
164
+ return penalty
165
+
166
+ def _clamp_01(self, value: Any) -> float:
167
+ try:
168
+ parsed = float(value)
169
+ except (TypeError, ValueError):
170
+ parsed = 0.0
171
+ return max(0.0, min(1.0, parsed))
inference.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pyright: reportMissingImports=false
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import re
9
+ from dataclasses import dataclass
10
+ from itertools import zip_longest
11
+ from typing import Any
12
+
13
+ from openai import OpenAI
14
+ import yaml
15
+
16
+ from env.rewards import RewardCalculator
17
+ from inference.metrics import EpisodeMetrics
18
+ from inference.model_wrapper import ModelWrapper, score_action_candidate
19
+ from inference.prompts import JUDGE_SYSTEM_PROMPT, heuristic_action
20
+ from inference.visualize import save_metrics_json, save_reward_curve, save_success_rate_history
21
+
22
+ try:
23
+ from my_env_v4 import MyEnvV4Action, MyEnvV4Env # type: ignore[import-not-found]
24
+
25
+ EXTERNAL_ENV_AVAILABLE = True
26
+ except Exception:
27
+ MyEnvV4Action = None
28
+ MyEnvV4Env = None
29
+ EXTERNAL_ENV_AVAILABLE = False
30
+
31
+
32
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
33
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
34
+ HF_TOKEN = os.getenv("HF_TOKEN")
35
+ LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
36
+
37
+ TASK_NAME = os.getenv("MY_ENV_V4_TASK", "cicd-debugger-task")
38
+ BENCHMARK = os.getenv("MY_ENV_V4_BENCHMARK", "cicd_debugger_env")
39
+
40
+ MAX_STEPS_DEFAULT = int(os.getenv("MAX_STEPS", "8"))
41
+ TEMPERATURE = float(os.getenv("TEMPERATURE", "0.2"))
42
+ MAX_TOKENS = int(os.getenv("MAX_TOKENS", "120"))
43
+ OFFLINE_INFERENCE = os.getenv("OFFLINE_INFERENCE", "0") == "1"
44
+
45
+
46
+ DEFAULT_ORIGINAL_CONFIG = """
47
+ name: CI
48
+ on: [push]
49
+ jobs:
50
+ test:
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - uses: actions/checkout@v4
54
+ - run: npm ci
55
+ - run: npm tset
56
+ """.strip()
57
+
58
+ DEFAULT_EXPECTED_CONFIG = """
59
+ name: CI
60
+ on: [push]
61
+ jobs:
62
+ test:
63
+ runs-on: ubuntu-latest
64
+ steps:
65
+ - uses: actions/checkout@v4
66
+ - run: npm ci
67
+ - run: npm test
68
+ """.strip()
69
+
70
+ DEFAULT_ERROR_MESSAGE = "command not found"
71
+
72
+
73
+ @dataclass
74
+ class LocalObservation:
75
+ config: str
76
+ error_message: str
77
+ logs: str
78
+ last_action_error: str | None = None
79
+
80
+
81
+ @dataclass
82
+ class LocalStepResult:
83
+ observation: LocalObservation
84
+ reward: float
85
+ done: bool
86
+ last_action_error: str | None = None
87
+
88
+
89
+ @dataclass
90
+ class LocalAction:
91
+ message: str
92
+
93
+
94
+ class LocalCICDDebuggerEnv:
95
+ def __init__(self, original_config: str, expected_config: str, error_message: str):
96
+ self.original_config = original_config
97
+ self.expected_config = expected_config
98
+ self.error_message = error_message
99
+ self.current_config = original_config
100
+
101
+ async def reset(self) -> LocalStepResult:
102
+ self.current_config = self.original_config
103
+ obs = LocalObservation(
104
+ config=self.current_config,
105
+ error_message=self.error_message,
106
+ logs="CI failed in test step: npm tset is not a valid command.",
107
+ last_action_error=None,
108
+ )
109
+ return LocalStepResult(observation=obs, reward=0.0, done=False, last_action_error=None)
110
+
111
+ async def step(self, action: LocalAction) -> LocalStepResult:
112
+ message = str(action.message or "").strip()
113
+ lower_message = message.lower()
114
+ previous = self.current_config
115
+ step_error: str | None = None
116
+ logs = "No effective change applied."
117
+
118
+ if _is_hacking_action(message):
119
+ step_error = "disallowed_hacking_pattern"
120
+ logs = "Rejected unsafe action pattern."
121
+ elif "npm tset" in lower_message and "npm test" in lower_message and "npm tset" in previous:
122
+ self.current_config = previous.replace("npm tset", "npm test")
123
+ logs = "Patched CI command typo from npm tset to npm test."
124
+ elif "replace" in lower_message and "npm test" in lower_message and "npm tset" in previous:
125
+ self.current_config = previous.replace("npm tset", "npm test")
126
+ logs = "Applied replace operation for broken test command."
127
+ elif "npm test" in lower_message and "npm tset" in previous:
128
+ self.current_config = previous.replace("npm tset", "npm test")
129
+ logs = "Applied inferred command fix."
130
+
131
+ done = "npm tset" not in self.current_config.lower() and "npm test" in self.current_config.lower()
132
+ reward = 1.0 if done else 0.0
133
+ err_msg = "" if done else self.error_message
134
+
135
+ obs = LocalObservation(
136
+ config=self.current_config,
137
+ error_message=err_msg,
138
+ logs=logs,
139
+ last_action_error=step_error,
140
+ )
141
+ return LocalStepResult(observation=obs, reward=reward, done=done, last_action_error=step_error)
142
+
143
+ async def close(self) -> None:
144
+ return None
145
+
146
+
147
+ class OpenAIJudgeAdapter:
148
+ def __init__(self, client: OpenAI, model_name: str):
149
+ self.client = client
150
+ self.model_name = model_name
151
+
152
+ def evaluate_fix(self, original: str, fixed: str, error: str) -> dict[str, float]:
153
+ prompt = (
154
+ "Evaluate CI config fix quality. Return JSON only with keys correctness, minimalism, quality in [0,1].\n\n"
155
+ f"Original:\n{original}\n\n"
156
+ f"Fixed:\n{fixed}\n\n"
157
+ f"Error:\n{error}\n"
158
+ )
159
+ default = {"correctness": 0.0, "minimalism": 0.0, "quality": 0.0}
160
+
161
+ try:
162
+ completion = self.client.chat.completions.create(
163
+ model=self.model_name,
164
+ messages=[
165
+ {"role": "system", "content": JUDGE_SYSTEM_PROMPT},
166
+ {"role": "user", "content": prompt},
167
+ ],
168
+ temperature=0.0,
169
+ max_tokens=120,
170
+ stream=False,
171
+ )
172
+ content = (completion.choices[0].message.content or "").strip()
173
+ except Exception:
174
+ return default
175
+
176
+ parsed = self._parse_scores(content)
177
+ return parsed if parsed else default
178
+
179
+ def _parse_scores(self, content: str) -> dict[str, float] | None:
180
+ decoder = json.JSONDecoder()
181
+ for idx, char in enumerate(content):
182
+ if char != "{":
183
+ continue
184
+ try:
185
+ obj, _ = decoder.raw_decode(content[idx:])
186
+ except json.JSONDecodeError:
187
+ continue
188
+ if isinstance(obj, dict):
189
+ return {
190
+ "correctness": self._clamp(obj.get("correctness", 0.0)),
191
+ "minimalism": self._clamp(obj.get("minimalism", 0.0)),
192
+ "quality": self._clamp(obj.get("quality", 0.0)),
193
+ }
194
+
195
+ fallback = {
196
+ "correctness": self._extract_regex(content, "correctness"),
197
+ "minimalism": self._extract_regex(content, "minimalism"),
198
+ "quality": self._extract_regex(content, "quality"),
199
+ }
200
+ if any(value > 0 for value in fallback.values()):
201
+ return fallback
202
+ return None
203
+
204
+ def _extract_regex(self, content: str, key: str) -> float:
205
+ match = re.search(rf"{key}\s*[:=\-]\s*([0-9]*\.?[0-9]+)", content, flags=re.IGNORECASE)
206
+ if not match:
207
+ return 0.0
208
+ return self._clamp(match.group(1))
209
+
210
+ def _clamp(self, value: Any) -> float:
211
+ try:
212
+ parsed = float(value)
213
+ except (TypeError, ValueError):
214
+ parsed = 0.0
215
+ return max(0.0, min(1.0, parsed))
216
+
217
+
218
+ def log_start(task: str, env_name: str, model: str) -> None:
219
+ print(f"[START] task={_single_line(task)} env={_single_line(env_name)} model={_single_line(model)}", flush=True)
220
+
221
+
222
+ def log_step(step: int, action: str, reward: float, done: bool, error: str | None) -> None:
223
+ done_val = str(done).lower()
224
+ error_val = _single_line(error) if error else "null"
225
+ action_val = _single_line(action)
226
+ print(f"[STEP] step={step} action={action_val} reward={reward:.2f} done={done_val} error={error_val}", flush=True)
227
+
228
+
229
+ def log_end(success: bool, steps: int, rewards: list[float]) -> None:
230
+ rewards_str = ",".join(f"{value:.2f}" for value in rewards)
231
+ print(f"[END] success={str(success).lower()} steps={steps} rewards={rewards_str}", flush=True)
232
+
233
+
234
+ def _single_line(value: Any) -> str:
235
+ return " ".join(str(value).replace("\n", " ").replace("\r", " ").split())
236
+
237
+
238
+ def _safe_float(value: Any) -> float:
239
+ try:
240
+ return float(value or 0.0)
241
+ except (TypeError, ValueError):
242
+ return 0.0
243
+
244
+
245
+ def _extract_observation(result: Any) -> Any:
246
+ return getattr(result, "observation", result)
247
+
248
+
249
+ def _extract_done(result: Any) -> bool:
250
+ return bool(getattr(result, "done", False))
251
+
252
+
253
+ def _extract_reward(result: Any) -> float:
254
+ return _safe_float(getattr(result, "reward", 0.0))
255
+
256
+
257
+ def _extract_error(result: Any, observation: Any) -> str | None:
258
+ result_error = getattr(result, "last_action_error", None)
259
+ if result_error:
260
+ return str(result_error)
261
+
262
+ if isinstance(observation, dict):
263
+ obs_err = observation.get("last_action_error")
264
+ if obs_err:
265
+ return str(obs_err)
266
+ else:
267
+ obs_err = getattr(observation, "last_action_error", None)
268
+ if obs_err:
269
+ return str(obs_err)
270
+
271
+ return None
272
+
273
+
274
+ def _extract_observation_text(observation: Any) -> str:
275
+ if isinstance(observation, dict):
276
+ for key in ("config", "current_config", "echoed_message"):
277
+ if key in observation and observation.get(key) is not None:
278
+ return str(observation.get(key))
279
+ return str(observation)
280
+
281
+ for attr in ("config", "current_config", "echoed_message"):
282
+ if hasattr(observation, attr):
283
+ value = getattr(observation, attr)
284
+ if value is not None:
285
+ return str(value)
286
+
287
+ return str(observation)
288
+
289
+
290
+ def _extract_observation_error_message(observation: Any) -> str:
291
+ if isinstance(observation, dict):
292
+ return str(observation.get("error_message", "") or "")
293
+ return str(getattr(observation, "error_message", "") or "")
294
+
295
+
296
+ def _count_changed_lines(previous_text: str, current_text: str) -> int:
297
+ previous_lines = (previous_text or "").splitlines()
298
+ current_lines = (current_text or "").splitlines()
299
+ changed = 0
300
+ for left, right in zip_longest(previous_lines, current_lines, fillvalue=""):
301
+ if left != right:
302
+ changed += 1
303
+ return changed
304
+
305
+
306
+ def _is_hacking_action(action_text: str) -> bool:
307
+ value = (action_text or "").lower()
308
+ patterns = (
309
+ "if: false",
310
+ "when: never",
311
+ "echo \"tests passed\"",
312
+ "echo 'tests passed'",
313
+ "exit 0",
314
+ "force success",
315
+ )
316
+ return any(token in value for token in patterns)
317
+
318
+
319
+ def _classify_action(action_text: str) -> str:
320
+ text = (action_text or "").lower()
321
+ if "read" in text and "log" in text:
322
+ return "read_logs"
323
+ if "analy" in text:
324
+ return "analyze_error"
325
+ if "propose" in text:
326
+ return "propose_fix"
327
+ if any(token in text for token in ("validate", "run test", "pipeline run", "verify")):
328
+ return "validate_fix"
329
+ return "edit_config"
330
+
331
+
332
+ def _select_action(
333
+ model_wrapper: ModelWrapper,
334
+ step: int,
335
+ config_text: str,
336
+ error_message: str,
337
+ history: list[str],
338
+ policy_mode: str,
339
+ trajectories: int,
340
+ ) -> str:
341
+ mode = (policy_mode or "imp").lower()
342
+
343
+ if mode == "sft":
344
+ return heuristic_action(config_text, error_message)
345
+
346
+ if mode == "direct":
347
+ return model_wrapper.generate_action(
348
+ step=step,
349
+ config_text=config_text,
350
+ error_message=error_message,
351
+ history=history,
352
+ )
353
+
354
+ candidates = model_wrapper.generate_candidates(
355
+ step=step,
356
+ config_text=config_text,
357
+ error_message=error_message,
358
+ history=history,
359
+ count=max(1, int(trajectories)),
360
+ )
361
+
362
+ if not candidates:
363
+ return heuristic_action(config_text, error_message)
364
+
365
+ observation = f"{config_text}\n{error_message}"
366
+ best = max(candidates, key=lambda item: score_action_candidate(observation, item, _is_hacking_action))
367
+ return best
368
+
369
+
370
+ def _build_action(action_class: Any, message: str) -> Any:
371
+ try:
372
+ return action_class(message=message)
373
+ except TypeError:
374
+ return action_class(message)
375
+
376
+
377
+ async def _load_environment(
378
+ original_config: str,
379
+ expected_config: str,
380
+ error_message: str,
381
+ force_local_env: bool,
382
+ ) -> tuple[Any, Any]:
383
+ if not force_local_env and EXTERNAL_ENV_AVAILABLE and LOCAL_IMAGE_NAME:
384
+ try:
385
+ env = await MyEnvV4Env.from_docker_image(LOCAL_IMAGE_NAME)
386
+ return env, MyEnvV4Action
387
+ except Exception:
388
+ pass
389
+
390
+ env = LocalCICDDebuggerEnv(
391
+ original_config=original_config,
392
+ expected_config=expected_config,
393
+ error_message=error_message,
394
+ )
395
+ return env, LocalAction
396
+
397
+
398
+ def _load_text(raw_value: str | None, file_path: str | None, fallback: str) -> str:
399
+ if raw_value:
400
+ return raw_value
401
+ if file_path:
402
+ with open(file_path, "r", encoding="utf-8") as handle:
403
+ return handle.read().strip()
404
+ return fallback
405
+
406
+
407
+ def parse_args() -> argparse.Namespace:
408
+ parser = argparse.ArgumentParser(description="Run OpenEnv-style CI/CD pipeline debugging inference loop")
409
+ parser.add_argument("--max-steps", type=int, default=MAX_STEPS_DEFAULT)
410
+ parser.add_argument("--task", default=TASK_NAME)
411
+ parser.add_argument("--benchmark", default=BENCHMARK)
412
+ parser.add_argument("--offline", action="store_true", default=OFFLINE_INFERENCE)
413
+ parser.add_argument("--policy-mode", choices=["sft", "imp", "direct"], default="imp")
414
+ parser.add_argument("--trajectories", type=int, default=3)
415
+ parser.add_argument("--force-local-env", action="store_true", default=False)
416
+
417
+ parser.add_argument("--original-config", default=None)
418
+ parser.add_argument("--original-config-file", default=None)
419
+ parser.add_argument("--expected-config", default=None)
420
+ parser.add_argument("--expected-config-file", default=None)
421
+ parser.add_argument("--error-message", default=DEFAULT_ERROR_MESSAGE)
422
+
423
+ return parser.parse_args()
424
+
425
+
426
+ async def run_episode(args: argparse.Namespace) -> int:
427
+ original_config = _load_text(args.original_config, args.original_config_file, DEFAULT_ORIGINAL_CONFIG)
428
+ expected_config = _load_text(args.expected_config, args.expected_config_file, DEFAULT_EXPECTED_CONFIG)
429
+ error_message = str(args.error_message or DEFAULT_ERROR_MESSAGE)
430
+
431
+ env = None
432
+ history: list[str] = []
433
+ steps_taken = 0
434
+ success = False
435
+ metrics = EpisodeMetrics()
436
+
437
+ offline_mode = bool(args.offline or not HF_TOKEN)
438
+ client: OpenAI | None = None
439
+ if not offline_mode:
440
+ client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN or "")
441
+
442
+ log_start(task=str(args.task), env_name=str(args.benchmark), model=MODEL_NAME)
443
+
444
+ try:
445
+ env, action_class = await _load_environment(
446
+ original_config=original_config,
447
+ expected_config=expected_config,
448
+ error_message=error_message,
449
+ force_local_env=bool(args.force_local_env),
450
+ )
451
+
452
+ judge_adapter = OpenAIJudgeAdapter(client, MODEL_NAME) if client is not None else None
453
+ reward_calculator = RewardCalculator(llm_judge=judge_adapter)
454
+ model_wrapper = ModelWrapper(
455
+ client=client,
456
+ model_name=MODEL_NAME,
457
+ temperature=TEMPERATURE,
458
+ max_tokens=MAX_TOKENS,
459
+ offline=offline_mode,
460
+ )
461
+
462
+ reset_result = await env.reset()
463
+ observation = _extract_observation(reset_result)
464
+ previous_config = original_config
465
+ current_error_message = error_message
466
+
467
+ for step in range(1, max(1, int(args.max_steps)) + 1):
468
+ config_text = _extract_observation_text(observation) or previous_config
469
+ obs_error = _extract_observation_error_message(observation)
470
+ if obs_error:
471
+ current_error_message = obs_error
472
+
473
+ action_text = _select_action(
474
+ model_wrapper=model_wrapper,
475
+ step=step,
476
+ config_text=config_text,
477
+ error_message=current_error_message,
478
+ history=history,
479
+ policy_mode=str(args.policy_mode),
480
+ trajectories=max(1, int(args.trajectories)),
481
+ )
482
+ action_obj = _build_action(action_class, action_text)
483
+
484
+ step_result = await env.step(action_obj)
485
+ observation = _extract_observation(step_result)
486
+
487
+ env_reward = _extract_reward(step_result)
488
+ done = _extract_done(step_result)
489
+ step_error = _extract_error(step_result, observation)
490
+ current_config = _extract_observation_text(observation) or config_text
491
+ obs_error = _extract_observation_error_message(observation)
492
+ if obs_error:
493
+ current_error_message = obs_error
494
+
495
+ action_type = _classify_action(action_text)
496
+ hacking_attempt = _is_hacking_action(action_text)
497
+
498
+ result_for_reward = {
499
+ "previous_config": previous_config,
500
+ "current_config": current_config,
501
+ "fixed_config": current_config,
502
+ "expected_config": expected_config,
503
+ "error": current_error_message,
504
+ "logs_analyzed": "log" in action_text.lower() or action_type == "read_logs",
505
+ "error_diagnosed": action_type in {"analyze_error", "propose_fix", "edit_config", "validate_fix"},
506
+ "fix_proposed": action_type in {"propose_fix", "edit_config"},
507
+ "pipeline_run": action_type == "validate_fix" and step_error is None,
508
+ "tests_passed": done,
509
+ "command_succeeded": step_error is None,
510
+ "changed_files_count": 1 if previous_config != current_config else 0,
511
+ "changed_lines_count": _count_changed_lines(previous_config, current_config),
512
+ "hacking_attempt": hacking_attempt,
513
+ }
514
+
515
+ calculated_reward = reward_calculator.calculate_step_reward(
516
+ state={
517
+ "step_count": step,
518
+ "previous_config": previous_config,
519
+ "expected_config": expected_config,
520
+ "original_config": original_config,
521
+ "error": current_error_message,
522
+ },
523
+ action=action_type,
524
+ result=result_for_reward,
525
+ original_config=original_config,
526
+ fixed_config=current_config,
527
+ error_message=current_error_message,
528
+ expected_config=expected_config,
529
+ metadata={"broken_token": "npm tset", "fixed_token": "npm test"},
530
+ )
531
+
532
+ combined_reward = round(float(calculated_reward) + float(env_reward), 4)
533
+ metrics.add_step(action=action_text, reward=combined_reward, error=step_error, done=done)
534
+ steps_taken = step
535
+
536
+ log_step(step=step, action=action_text, reward=combined_reward, done=done, error=step_error)
537
+
538
+ history.append(f"step={step} action={_single_line(action_text)} reward={combined_reward:.2f}")
539
+ previous_config = current_config
540
+
541
+ if done:
542
+ success = step_error is None and not hacking_attempt
543
+ break
544
+
545
+ except Exception:
546
+ success = False
547
+ finally:
548
+ try:
549
+ save_reward_curve(metrics.rewards)
550
+ save_metrics_json(metrics.summary())
551
+ save_success_rate_history([success])
552
+ except Exception:
553
+ pass
554
+
555
+ if env is not None:
556
+ try:
557
+ await env.close()
558
+ except Exception:
559
+ pass
560
+
561
+ log_end(success=success, steps=steps_taken, rewards=metrics.rewards)
562
+
563
+ return 0
564
+
565
+
566
+ def main() -> int:
567
+ args = parse_args()
568
+ return asyncio.run(run_episode(args))
569
+
570
+
571
+ if __name__ == "__main__":
572
+ raise SystemExit(main())
inference/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from inference.metrics import EpisodeMetrics
2
+ from inference.model_wrapper import ModelWrapper
3
+
4
+ __all__ = ["EpisodeMetrics", "ModelWrapper"]
inference/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (316 Bytes). View file
 
inference/__pycache__/metrics.cpython-312.pyc ADDED
Binary file (3.58 kB). View file
 
inference/__pycache__/model_wrapper.cpython-312.pyc ADDED
Binary file (4.58 kB). View file
 
inference/__pycache__/prompts.cpython-312.pyc ADDED
Binary file (3.21 kB). View file
 
inference/__pycache__/visualize.cpython-312.pyc ADDED
Binary file (2.75 kB). View file
 
inference/metrics.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class EpisodeMetrics:
8
+ rewards: list[float] = field(default_factory=list)
9
+ actions: list[str] = field(default_factory=list)
10
+ errors: list[str | None] = field(default_factory=list)
11
+ dones: list[bool] = field(default_factory=list)
12
+
13
+ def add_step(self, action: str, reward: float, error: str | None, done: bool) -> None:
14
+ self.actions.append(action)
15
+ self.rewards.append(float(reward))
16
+ self.errors.append(error)
17
+ self.dones.append(bool(done))
18
+
19
+ @property
20
+ def steps(self) -> int:
21
+ return len(self.rewards)
22
+
23
+ @property
24
+ def total_reward(self) -> float:
25
+ return round(sum(self.rewards), 4)
26
+
27
+ @property
28
+ def average_reward(self) -> float:
29
+ if not self.rewards:
30
+ return 0.0
31
+ return round(self.total_reward / len(self.rewards), 4)
32
+
33
+ @property
34
+ def success_rate(self) -> float:
35
+ if not self.dones:
36
+ return 0.0
37
+ successes = sum(1 for flag in self.dones if flag)
38
+ return round(successes / len(self.dones), 4)
39
+
40
+ @property
41
+ def failure_reasons(self) -> dict[str, int]:
42
+ counts: dict[str, int] = {}
43
+ for err in self.errors:
44
+ if not err:
45
+ continue
46
+ counts[err] = counts.get(err, 0) + 1
47
+ return counts
48
+
49
+ def summary(self) -> dict[str, float | int | dict[str, int]]:
50
+ return {
51
+ "steps": self.steps,
52
+ "total_reward": self.total_reward,
53
+ "average_reward": self.average_reward,
54
+ "success_rate": self.success_rate,
55
+ "failure_reasons": self.failure_reasons,
56
+ }
inference/model_wrapper.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Iterable
5
+
6
+ from openai import OpenAI
7
+
8
+ from inference.prompts import SYSTEM_PROMPT, build_user_prompt, heuristic_action, sanitize_action_text
9
+
10
+
11
+ @dataclass
12
+ class ModelWrapper:
13
+ client: OpenAI | None
14
+ model_name: str
15
+ temperature: float
16
+ max_tokens: int
17
+ offline: bool
18
+
19
+ def generate_action(
20
+ self,
21
+ step: int,
22
+ config_text: str,
23
+ error_message: str,
24
+ history: list[str],
25
+ available_actions: Iterable[str] | None = None,
26
+ ) -> str:
27
+ if self.offline or self.client is None:
28
+ return heuristic_action(config_text, error_message, available_actions)
29
+
30
+ user_prompt = build_user_prompt(
31
+ step=step,
32
+ config_text=config_text,
33
+ error_message=error_message,
34
+ history=history,
35
+ available_actions=available_actions,
36
+ )
37
+
38
+ try:
39
+ completion = self.client.chat.completions.create(
40
+ model=self.model_name,
41
+ messages=[
42
+ {"role": "system", "content": SYSTEM_PROMPT},
43
+ {"role": "user", "content": user_prompt},
44
+ ],
45
+ temperature=self.temperature,
46
+ max_tokens=self.max_tokens,
47
+ stream=False,
48
+ )
49
+ generated = completion.choices[0].message.content or ""
50
+ action = sanitize_action_text(
51
+ generated,
52
+ fallback=heuristic_action(config_text, error_message, available_actions),
53
+ )
54
+ return action
55
+ except Exception:
56
+ return heuristic_action(config_text, error_message, available_actions)
57
+
58
+ def generate_candidates(
59
+ self,
60
+ step: int,
61
+ config_text: str,
62
+ error_message: str,
63
+ history: list[str],
64
+ count: int,
65
+ available_actions: Iterable[str] | None = None,
66
+ ) -> list[str]:
67
+ candidates = [heuristic_action(config_text, error_message, available_actions)]
68
+
69
+ for idx in range(max(1, count)):
70
+ action = self.generate_action(
71
+ step=step,
72
+ config_text=config_text,
73
+ error_message=error_message,
74
+ history=history + [f"candidate={idx}"],
75
+ available_actions=available_actions,
76
+ )
77
+ if action:
78
+ candidates.append(action)
79
+
80
+ deduped: list[str] = []
81
+ seen: set[str] = set()
82
+ for candidate in candidates:
83
+ normalized = candidate.strip()
84
+ if not normalized:
85
+ continue
86
+ if normalized in seen:
87
+ continue
88
+ seen.add(normalized)
89
+ deduped.append(normalized)
90
+
91
+ return deduped
92
+
93
+
94
+ def score_action_candidate(observation_text: str, action_text: str, hacking_predicate: Any) -> float:
95
+ obs = (observation_text or "").lower()
96
+ action = (action_text or "").lower()
97
+
98
+ score = 0.0
99
+ if "npm tset" in obs and "npm test" in action:
100
+ score += 2.0
101
+ if "yaml" in obs and any(token in action for token in ("indent", "syntax", "yaml")):
102
+ score += 1.0
103
+ if "dependency" in obs and any(token in action for token in ("install", "dependency", "package")):
104
+ score += 1.0
105
+ if hacking_predicate(action_text):
106
+ score -= 3.0
107
+
108
+ score += min(len(action_text) / 200.0, 0.25)
109
+ return score
inference/prompts.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import textwrap
4
+ from typing import Iterable
5
+
6
+
7
+ SYSTEM_PROMPT = textwrap.dedent(
8
+ """
9
+ You are a CI/CD pipeline debugger assistant.
10
+ Return exactly one single-line action describing the next debugging move.
11
+ Do not output markdown. Do not include explanations.
12
+ """
13
+ ).strip()
14
+
15
+ JUDGE_SYSTEM_PROMPT = textwrap.dedent(
16
+ """
17
+ You are a strict CI/CD judge.
18
+ Return JSON only with keys correctness, minimalism, quality and values in [0,1].
19
+ """
20
+ ).strip()
21
+
22
+
23
+ def build_user_prompt(
24
+ step: int,
25
+ config_text: str,
26
+ error_message: str,
27
+ history: list[str],
28
+ available_actions: Iterable[str] | None = None,
29
+ ) -> str:
30
+ history_text = "\n".join(history[-5:]) if history else "None"
31
+ actions_text = ", ".join(available_actions) if available_actions else "read_logs, analyze_error, propose_fix, edit_config, validate_fix"
32
+
33
+ return textwrap.dedent(
34
+ f"""
35
+ Step: {step}
36
+
37
+ Current config:
38
+ {config_text}
39
+
40
+ Current error:
41
+ {error_message}
42
+
43
+ Recent history:
44
+ {history_text}
45
+
46
+ Available action categories:
47
+ {actions_text}
48
+
49
+ Output one actionable single-line fix/debug action.
50
+ """
51
+ ).strip()
52
+
53
+
54
+ def sanitize_action_text(raw_text: str, fallback: str = "read logs and analyze failing command") -> str:
55
+ text = (raw_text or "").strip()
56
+ if not text:
57
+ return fallback
58
+ text = text.replace("\n", " ").replace("\r", " ")
59
+ text = " ".join(text.split())
60
+ return text or fallback
61
+
62
+
63
+ def heuristic_action(
64
+ config_text: str,
65
+ error_message: str,
66
+ available_actions: Iterable[str] | None = None,
67
+ ) -> str:
68
+ lower_cfg = (config_text or "").lower()
69
+ lower_err = (error_message or "").lower()
70
+
71
+ if "npm tset" in lower_cfg:
72
+ return "edit_config: replace npm tset with npm test"
73
+
74
+ if "yaml" in lower_err or "mapping values are not allowed" in lower_err:
75
+ return "edit_config: fix YAML indentation and syntax"
76
+
77
+ if "module not found" in lower_err or "dependency" in lower_err:
78
+ return "propose_fix: install missing dependency and update pipeline install step"
79
+
80
+ return "read_logs: inspect failing stage logs and identify root cause"
inference/visualize.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ def save_reward_curve(rewards: list[float], output_path: str = "artifacts/reward_curve.csv") -> str:
8
+ path = Path(output_path)
9
+ path.parent.mkdir(parents=True, exist_ok=True)
10
+
11
+ with path.open("w", encoding="utf-8") as handle:
12
+ handle.write("step,reward\n")
13
+ for idx, reward in enumerate(rewards, start=1):
14
+ handle.write(f"{idx},{float(reward):.4f}\n")
15
+
16
+ return str(path)
17
+
18
+
19
+ def save_success_rate_history(success_flags: list[bool], output_path: str = "artifacts/success_rate.csv") -> str:
20
+ path = Path(output_path)
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+
23
+ running = 0
24
+ with path.open("w", encoding="utf-8") as handle:
25
+ handle.write("episode,success,success_rate\n")
26
+ for idx, flag in enumerate(success_flags, start=1):
27
+ if flag:
28
+ running += 1
29
+ rate = running / idx
30
+ handle.write(f"{idx},{int(flag)},{rate:.4f}\n")
31
+
32
+ return str(path)
33
+
34
+
35
+ def save_metrics_json(metrics: dict, output_path: str = "artifacts/metrics.json") -> str:
36
+ path = Path(output_path)
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+
39
+ with path.open("w", encoding="utf-8") as handle:
40
+ json.dump(metrics, handle, indent=2, sort_keys=True)
41
+
42
+ return str(path)
openenv.yaml ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "0.1"
2
+ name: "cicd-debugger-env"
3
+ description: "AI environment for debugging CI/CD pipelines with deterministic + LLM grading"
4
+
5
+ entrypoint:
6
+ command: "python"
7
+ args:
8
+ - "inference.py"
9
+
10
+ interface:
11
+ observation_type: "json"
12
+ action_type: "json"
13
+ max_steps: 30
14
+
15
+ tasks:
16
+ - id: "cicd-debugger-001"
17
+ description: "Fix typo in npm test command"
18
+ difficulty: "easy"
19
+ metadata:
20
+ broken_token: "npm tset"
21
+ fixed_token: "npm test"
22
+
23
+ - id: "cicd-debugger-002"
24
+ description: "Fix YAML indentation in test job"
25
+ difficulty: "easy"
26
+
27
+ - id: "cicd-debugger-003"
28
+ description: "Fix missing checkout step"
29
+ difficulty: "easy"
30
+
31
+ - id: "cicd-debugger-004"
32
+ description: "Fix wrong Python version pin"
33
+ difficulty: "easy"
34
+
35
+ - id: "cicd-debugger-005"
36
+ description: "Fix dependency install command"
37
+ difficulty: "medium"
38
+
39
+ - id: "cicd-debugger-006"
40
+ description: "Fix cache key mismatch"
41
+ difficulty: "medium"
42
+
43
+ - id: "cicd-debugger-007"
44
+ description: "Fix environment variable propagation"
45
+ difficulty: "medium"
46
+
47
+ - id: "cicd-debugger-008"
48
+ description: "Fix test stage permissions"
49
+ difficulty: "medium"
50
+
51
+ - id: "cicd-debugger-009"
52
+ description: "Fix artifact upload path"
53
+ difficulty: "medium"
54
+
55
+ - id: "cicd-debugger-010"
56
+ description: "Fix matrix include-exclude logic"
57
+ difficulty: "hard"
58
+
59
+ - id: "cicd-debugger-011"
60
+ description: "Fix conditional deploy stage logic"
61
+ difficulty: "hard"
62
+
63
+ - id: "cicd-debugger-012"
64
+ description: "Fix multi-job dependency ordering"
65
+ difficulty: "hard"
66
+
67
+ - id: "cicd-debugger-013"
68
+ description: "Fix cross-platform shell command behavior"
69
+ difficulty: "hard"
pyproject.toml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "cicd-debugger-env"
3
+ version = "0.1.0"
4
+ description = "OpenEnv CI/CD pipeline debugging environment with hybrid grading and reward shaping"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "openai",
9
+ "pyyaml",
10
+ "fastapi",
11
+ "uvicorn",
12
+ "openenv-core",
13
+ "transformers",
14
+ "torch",
15
+ ]
16
+
17
+ [project.scripts]
18
+ server = "server.app:main"
19
+
20
+ [build-system]
21
+ requires = ["setuptools>=68", "wheel"]
22
+ build-backend = "setuptools.build_meta"
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ openai
2
+ pyyaml
3
+ fastapi
4
+ uvicorn
5
+ openenv-core
6
+ transformers
7
+ torch
server/__init__.py ADDED
File without changes
server/app.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI
4
+ import uvicorn
5
+
6
+
7
+ app = FastAPI(title="CI/CD Debugger OpenEnv Server")
8
+
9
+
10
+ @app.get("/health")
11
+ def health() -> dict[str, str]:
12
+ return {"status": "ok"}
13
+
14
+
15
+ def main() -> None:
16
+ uvicorn.run(app, host="0.0.0.0", port=8000)
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
tests/__init__.py ADDED
File without changes
tests/__pycache__/test_day2_engine.cpython-312.pyc ADDED
Binary file (8.62 kB). View file
 
tests/__pycache__/test_inference.cpython-312.pyc ADDED
Binary file (3.13 kB). View file
 
tests/__pycache__/test_judge.cpython-312.pyc ADDED
Binary file (3.52 kB). View file
 
tests/test_day2_engine.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from env.anti_hacking import AntiHackingDetector
4
+ from env.graders.deterministic import DeterministicGrader
5
+ from env.hidden_tests import HiddenTestRunner
6
+ from env.rewards import RewardCalculator
7
+
8
+
9
+ EXPECTED_CONFIG = """
10
+ name: CI
11
+ on: [push]
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - run: npm ci
18
+ - run: npm test
19
+ """
20
+
21
+ WRONG_CONFIG = """
22
+ name: CI
23
+ on: [push]
24
+ jobs:
25
+ test:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - run: npm ci
30
+ - run: npm tset
31
+ """
32
+
33
+ BROKEN_YAML = """
34
+ name CI
35
+ jobs:
36
+ test:
37
+ steps
38
+ - run npm test
39
+ """
40
+
41
+
42
+ class FakeJudge:
43
+ def evaluate_fix(self, original, fixed, error):
44
+ return {
45
+ "correctness": 0.9,
46
+ "minimalism": 0.8,
47
+ "quality": 0.9,
48
+ }
49
+
50
+
51
+ class Day2EngineTests(unittest.TestCase):
52
+ def setUp(self):
53
+ self.grader = DeterministicGrader()
54
+ self.detector = AntiHackingDetector()
55
+ self.hidden_runner = HiddenTestRunner(grader=self.grader)
56
+ self.reward_calculator = RewardCalculator(
57
+ llm_judge=FakeJudge(),
58
+ anti_hacking_detector=self.detector,
59
+ deterministic_grader=self.grader,
60
+ hidden_test_runner=self.hidden_runner,
61
+ )
62
+
63
+ def test_deterministic_grader_high_for_correct_fix(self):
64
+ score = self.grader.grade(EXPECTED_CONFIG, EXPECTED_CONFIG)
65
+ self.assertGreaterEqual(score, 0.85)
66
+ self.assertLessEqual(score, 1.0)
67
+
68
+ def test_deterministic_grader_low_for_wrong_fix(self):
69
+ score = self.grader.grade(WRONG_CONFIG, EXPECTED_CONFIG)
70
+ self.assertLess(score, 0.6)
71
+
72
+ def test_deterministic_grader_penalizes_broken_yaml(self):
73
+ score = self.grader.grade(BROKEN_YAML, EXPECTED_CONFIG)
74
+ self.assertLess(score, 0.4)
75
+
76
+ def test_deterministic_grader_is_reproducible(self):
77
+ first = self.grader.grade(WRONG_CONFIG, EXPECTED_CONFIG)
78
+ second = self.grader.grade(WRONG_CONFIG, EXPECTED_CONFIG)
79
+ self.assertEqual(first, second)
80
+
81
+ def test_anti_hacking_detects_stage_skipping(self):
82
+ config = EXPECTED_CONFIG + "\n if: false\n"
83
+ penalty = self.detector.penalty_stage_skipping(config)
84
+ self.assertLess(penalty, 0.0)
85
+
86
+ def test_anti_hacking_detects_fake_success(self):
87
+ config = EXPECTED_CONFIG.replace("npm test", 'echo "tests passed" && exit 0')
88
+ penalty = self.detector.penalty_fake_success(config)
89
+ self.assertLess(penalty, 0.0)
90
+
91
+ def test_anti_hacking_detects_breaking_pipeline(self):
92
+ previous_config = """
93
+ stages:
94
+ - build
95
+ - test
96
+ jobs:
97
+ build:
98
+ run: npm ci
99
+ test:
100
+ run: npm test
101
+ """
102
+ new_config = """
103
+ stages:
104
+ - build
105
+ jobs:
106
+ build:
107
+ run: npm ci
108
+ """
109
+ penalty = self.detector.penalty_breaking_pipeline(previous_config, new_config)
110
+ self.assertLess(penalty, 0.0)
111
+
112
+ def test_anti_hacking_detects_excessive_edits(self):
113
+ penalty = self.detector.penalty_excessive_edits(changed_files_count=12, changed_lines_count=400)
114
+ self.assertLess(penalty, 0.0)
115
+
116
+ def test_anti_hacking_detects_timeout_abuse(self):
117
+ penalty = self.detector.penalty_timeout_abuse(step_count=25)
118
+ self.assertLess(penalty, 0.0)
119
+
120
+ def test_hidden_tests_returns_high_pass_rate_for_good_fix(self):
121
+ pass_rate = self.hidden_runner.evaluate_fix(
122
+ fixed_config=EXPECTED_CONFIG,
123
+ expected_config=EXPECTED_CONFIG,
124
+ )
125
+ self.assertGreaterEqual(pass_rate, 0.8)
126
+
127
+ def test_hidden_tests_returns_lower_pass_rate_for_bad_fix(self):
128
+ pass_rate = self.hidden_runner.evaluate_fix(
129
+ fixed_config=WRONG_CONFIG,
130
+ expected_config=EXPECTED_CONFIG,
131
+ )
132
+ self.assertLess(pass_rate, 0.8)
133
+
134
+ def test_reward_calculator_positive_for_good_behavior(self):
135
+ state = {
136
+ "step_count": 3,
137
+ "previous_config": WRONG_CONFIG,
138
+ "expected_config": EXPECTED_CONFIG,
139
+ "original_config": WRONG_CONFIG,
140
+ "error": "command not found",
141
+ }
142
+ result = {
143
+ "logs_analyzed": True,
144
+ "error_diagnosed": True,
145
+ "fix_proposed": True,
146
+ "pipeline_run": True,
147
+ "tests_passed": True,
148
+ "command_succeeded": True,
149
+ "current_config": EXPECTED_CONFIG,
150
+ "expected_config": EXPECTED_CONFIG,
151
+ "changed_files_count": 1,
152
+ "changed_lines_count": 1,
153
+ }
154
+
155
+ reward = self.reward_calculator.calculate_step_reward(
156
+ state=state,
157
+ action="validate_fix",
158
+ result=result,
159
+ original_config=WRONG_CONFIG,
160
+ fixed_config=EXPECTED_CONFIG,
161
+ error_message="command not found",
162
+ expected_config=EXPECTED_CONFIG,
163
+ metadata={"broken_token": "npm tset", "fixed_token": "npm test"},
164
+ )
165
+
166
+ self.assertGreater(reward, 0.5)
167
+
168
+ def test_reward_calculator_penalizes_hacking(self):
169
+ hacked_config = EXPECTED_CONFIG.replace("npm test", 'echo "tests passed" && exit 0') + "\n if: false\n"
170
+ state = {
171
+ "step_count": 25,
172
+ "previous_config": EXPECTED_CONFIG,
173
+ "changed_files_count": 15,
174
+ "changed_lines_count": 500,
175
+ }
176
+ result = {
177
+ "current_config": hacked_config,
178
+ "expected_config": EXPECTED_CONFIG,
179
+ "hacking_attempt": True,
180
+ "changed_files_count": 15,
181
+ "changed_lines_count": 500,
182
+ }
183
+
184
+ reward = self.reward_calculator.calculate_step_reward(
185
+ state=state,
186
+ action="edit_config",
187
+ result=result,
188
+ original_config=EXPECTED_CONFIG,
189
+ fixed_config=hacked_config,
190
+ error_message="",
191
+ expected_config=EXPECTED_CONFIG,
192
+ )
193
+
194
+ self.assertLess(reward, 0.0)
195
+
196
+
197
+ if __name__ == "__main__":
198
+ unittest.main()
tests/test_inference.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+ import unittest
7
+
8
+
9
+ class InferenceOutputFormatTests(unittest.TestCase):
10
+ def test_inference_prints_required_markers(self):
11
+ project_root = Path(__file__).resolve().parents[1]
12
+ env = os.environ.copy()
13
+ env["OFFLINE_INFERENCE"] = "1"
14
+
15
+ completed = subprocess.run(
16
+ [sys.executable, "inference.py", "--max-steps", "3", "--offline", "--force-local-env"],
17
+ cwd=project_root,
18
+ capture_output=True,
19
+ text=True,
20
+ env=env,
21
+ check=True,
22
+ )
23
+
24
+ lines = [line.strip() for line in completed.stdout.splitlines() if line.strip()]
25
+ self.assertGreaterEqual(len(lines), 3)
26
+ self.assertTrue(lines[0].startswith("[START] "))
27
+ self.assertTrue(lines[-1].startswith("[END] "))
28
+
29
+ start_pattern = re.compile(r"^\[START\] task=\S+ env=\S+ model=.+$")
30
+ step_pattern = re.compile(
31
+ r"^\[STEP\] step=\d+ action=.* reward=-?\d+\.\d{2} done=(true|false) error=(null|.+)$"
32
+ )
33
+ end_pattern = re.compile(
34
+ r"^\[END\] success=(true|false) steps=\d+ rewards=(-?\d+\.\d{2}(,-?\d+\.\d{2})*)?$"
35
+ )
36
+
37
+ self.assertRegex(lines[0], start_pattern)
38
+
39
+ step_lines = [line for line in lines if line.startswith("[STEP] ")]
40
+ self.assertTrue(step_lines)
41
+ for line in step_lines:
42
+ self.assertRegex(line, step_pattern)
43
+
44
+ self.assertRegex(lines[-1], end_pattern)
45
+
46
+ for line in lines:
47
+ self.assertTrue(
48
+ line.startswith("[START] ") or line.startswith("[STEP] ") or line.startswith("[END] "),
49
+ f"Unexpected output line: {line}",
50
+ )
51
+
52
+
53
+ if __name__ == "__main__":
54
+ unittest.main()
tests/test_judge.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from env.graders.llm_judge import LLMJudge
4
+
5
+
6
+ class FakeModel:
7
+ def __init__(self, payload, raise_error: bool = False):
8
+ self.payload = payload
9
+ self.raise_error = raise_error
10
+
11
+ def __call__(self, prompt, **kwargs):
12
+ if self.raise_error:
13
+ raise RuntimeError("model failure")
14
+ return [{"generated_text": self.payload}]
15
+
16
+
17
+ class LLMJudgeTests(unittest.TestCase):
18
+ def test_good_json_scores_are_parsed(self):
19
+ judge = LLMJudge(FakeModel('{"correctness": 1.0, "minimalism": 0.8, "quality": 0.9}'))
20
+ result = judge.evaluate_fix("npm tset", "npm test", "command not found")
21
+
22
+ self.assertGreaterEqual(result["correctness"], 0.9)
23
+ self.assertGreaterEqual(result["minimalism"], 0.7)
24
+ self.assertGreaterEqual(result["quality"], 0.8)
25
+
26
+ def test_regex_fallback_for_noisy_output(self):
27
+ noisy = "Correctness: 0.7\nMinimalism: 0.6\nQuality: 0.75"
28
+ judge = LLMJudge(FakeModel(noisy))
29
+ result = judge.evaluate_fix("a", "b", "err")
30
+
31
+ self.assertAlmostEqual(result["correctness"], 0.7)
32
+ self.assertAlmostEqual(result["minimalism"], 0.6)
33
+ self.assertAlmostEqual(result["quality"], 0.75)
34
+
35
+ def test_partial_fields_default_to_zero(self):
36
+ judge = LLMJudge(FakeModel('{"correctness": 0.8}'))
37
+ result = judge.evaluate_fix("a", "b", "err")
38
+
39
+ self.assertAlmostEqual(result["correctness"], 0.8)
40
+ self.assertAlmostEqual(result["minimalism"], 0.0)
41
+ self.assertAlmostEqual(result["quality"], 0.0)
42
+
43
+ def test_model_failure_returns_zeroes(self):
44
+ judge = LLMJudge(FakeModel("", raise_error=True))
45
+ result = judge.evaluate_fix("a", "b", "err")
46
+
47
+ self.assertEqual(result, {"correctness": 0.0, "minimalism": 0.0, "quality": 0.0})
48
+
49
+
50
+ if __name__ == "__main__":
51
+ unittest.main()
uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
validate-submission.sh ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # validate-submission.sh - OpenEnv Submission Validator
4
+ #
5
+ # Checks that your HF Space is live, Docker image builds, and openenv validate passes.
6
+ #
7
+ # Usage:
8
+ # ./validate-submission.sh <ping_url> [repo_dir]
9
+
10
+ set -uo pipefail
11
+
12
+ DOCKER_BUILD_TIMEOUT=600
13
+ if [ -t 1 ]; then
14
+ RED='\033[0;31m'
15
+ GREEN='\033[0;32m'
16
+ YELLOW='\033[1;33m'
17
+ BOLD='\033[1m'
18
+ NC='\033[0m'
19
+ else
20
+ RED='' GREEN='' YELLOW='' BOLD='' NC=''
21
+ fi
22
+
23
+ run_with_timeout() {
24
+ local secs="$1"; shift
25
+ if command -v timeout &>/dev/null; then
26
+ timeout "$secs" "$@"
27
+ elif command -v gtimeout &>/dev/null; then
28
+ gtimeout "$secs" "$@"
29
+ else
30
+ "$@" &
31
+ local pid=$!
32
+ ( sleep "$secs" && kill "$pid" 2>/dev/null ) &
33
+ local watcher=$!
34
+ wait "$pid" 2>/dev/null
35
+ local rc=$?
36
+ kill "$watcher" 2>/dev/null
37
+ wait "$watcher" 2>/dev/null
38
+ return $rc
39
+ fi
40
+ }
41
+
42
+ portable_mktemp() {
43
+ local prefix="${1:-validate}"
44
+ mktemp "${TMPDIR:-/tmp}/${prefix}-XXXXXX" 2>/dev/null || mktemp
45
+ }
46
+
47
+ CLEANUP_FILES=()
48
+ cleanup() { rm -f "${CLEANUP_FILES[@]+"${CLEANUP_FILES[@]}"}"; }
49
+ trap cleanup EXIT
50
+
51
+ PING_URL="${1:-}"
52
+ REPO_DIR="${2:-.}"
53
+
54
+ if [ -z "$PING_URL" ]; then
55
+ printf "Usage: %s <ping_url> [repo_dir]\n" "$0"
56
+ printf "\n"
57
+ printf " ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)\n"
58
+ printf " repo_dir Path to your repo (default: current directory)\n"
59
+ exit 1
60
+ fi
61
+
62
+ if ! REPO_DIR="$(cd "$REPO_DIR" 2>/dev/null && pwd)"; then
63
+ printf "Error: directory '%s' not found\n" "${2:-.}"
64
+ exit 1
65
+ fi
66
+
67
+ PING_URL="${PING_URL%/}"
68
+ PASS=0
69
+
70
+ log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
71
+ pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
72
+ fail() { log "${RED}FAILED${NC} -- $1"; }
73
+ hint() { printf " ${YELLOW}Hint:${NC} %b\n" "$1"; }
74
+ stop_at() {
75
+ printf "\n"
76
+ printf "${RED}${BOLD}Validation stopped at %s.${NC} Fix the above before continuing.\n" "$1"
77
+ exit 1
78
+ }
79
+
80
+ printf "\n"
81
+ printf "${BOLD}========================================${NC}\n"
82
+ printf "${BOLD} OpenEnv Submission Validator${NC}\n"
83
+ printf "${BOLD}========================================${NC}\n"
84
+ log "Repo: $REPO_DIR"
85
+ log "Ping URL: $PING_URL"
86
+ printf "\n"
87
+
88
+ log "${BOLD}Step 1/3: Pinging HF Space${NC} ($PING_URL/reset) ..."
89
+
90
+ CURL_OUTPUT=$(portable_mktemp "validate-curl")
91
+ CLEANUP_FILES+=("$CURL_OUTPUT")
92
+ HTTP_CODE=$(curl -s -o "$CURL_OUTPUT" -w "%{http_code}" -X POST \
93
+ -H "Content-Type: application/json" -d '{}' \
94
+ "$PING_URL/reset" --max-time 30 2>"$CURL_OUTPUT" || printf "000")
95
+
96
+ if [ "$HTTP_CODE" = "200" ]; then
97
+ pass "HF Space is live and responds to /reset"
98
+ elif [ "$HTTP_CODE" = "000" ]; then
99
+ fail "HF Space not reachable (connection failed or timed out)"
100
+ hint "Check your network connection and that the Space is running."
101
+ hint "Try: curl -s -o /dev/null -w '%{http_code}' -X POST $PING_URL/reset"
102
+ stop_at "Step 1"
103
+ else
104
+ fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
105
+ hint "Make sure your Space is running and the URL is correct."
106
+ stop_at "Step 1"
107
+ fi
108
+
109
+ log "${BOLD}Step 2/3: Running docker build${NC} ..."
110
+
111
+ if ! command -v docker &>/dev/null; then
112
+ fail "docker command not found"
113
+ hint "Install Docker: https://docs.docker.com/get-docker/"
114
+ stop_at "Step 2"
115
+ fi
116
+
117
+ if [ -f "$REPO_DIR/Dockerfile" ]; then
118
+ DOCKER_CONTEXT="$REPO_DIR"
119
+ elif [ -f "$REPO_DIR/server/Dockerfile" ]; then
120
+ DOCKER_CONTEXT="$REPO_DIR/server"
121
+ else
122
+ fail "No Dockerfile found in repo root or server/ directory"
123
+ stop_at "Step 2"
124
+ fi
125
+
126
+ BUILD_OK=false
127
+ BUILD_OUTPUT=$(run_with_timeout "$DOCKER_BUILD_TIMEOUT" docker build "$DOCKER_CONTEXT" 2>&1) && BUILD_OK=true
128
+
129
+ if [ "$BUILD_OK" = true ]; then
130
+ pass "Docker build succeeded"
131
+ else
132
+ fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
133
+ printf "%s\n" "$BUILD_OUTPUT" | tail -20
134
+ stop_at "Step 2"
135
+ fi
136
+
137
+ log "${BOLD}Step 3/3: Running openenv validate${NC} ..."
138
+
139
+ if ! command -v openenv &>/dev/null; then
140
+ fail "openenv command not found"
141
+ hint "Install it: pip install openenv-core"
142
+ stop_at "Step 3"
143
+ fi
144
+
145
+ VALIDATE_OK=false
146
+ VALIDATE_OUTPUT=$(cd "$REPO_DIR" && openenv validate 2>&1) && VALIDATE_OK=true
147
+
148
+ if [ "$VALIDATE_OK" = true ]; then
149
+ pass "openenv validate passed"
150
+ else
151
+ fail "openenv validate failed"
152
+ printf "%s\n" "$VALIDATE_OUTPUT"
153
+ stop_at "Step 3"
154
+ fi
155
+
156
+ printf "\n"
157
+ printf "${BOLD}========================================${NC}\n"
158
+ printf "${GREEN}${BOLD} All 3/3 checks passed!${NC}\n"
159
+ printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
160
+ printf "${BOLD}========================================${NC}\n"
161
+ printf "\n"
162
+
163
+ exit 0