local-coding-agent 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Benchmark execution: run one model against a disposable fixture and judge it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from time import perf_counter_ns
|
|
10
|
+
from typing import Any, Mapping, Protocol, Sequence
|
|
11
|
+
|
|
12
|
+
from ..controller import Controller
|
|
13
|
+
|
|
14
|
+
from ._cases import BenchmarkCase, default_cases
|
|
15
|
+
from ._judge import _judge_patch, _validate_patch_for_case
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChatModel(Protocol):
|
|
19
|
+
def chat(self, messages: list[dict[str, Any]], *, tools: list[dict[str, Any]]) -> dict[str, Any]: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InstrumentedModel:
|
|
23
|
+
"""Collect metrics returned by Ollama without changing the model response."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, model: ChatModel) -> None:
|
|
26
|
+
self.model = model
|
|
27
|
+
self.model_calls = 0
|
|
28
|
+
self.total_duration_ns = 0
|
|
29
|
+
self.load_duration_ns = 0
|
|
30
|
+
self.prompt_tokens = 0
|
|
31
|
+
self.prompt_eval_duration_ns = 0
|
|
32
|
+
self.eval_count = 0
|
|
33
|
+
self.eval_duration_ns = 0
|
|
34
|
+
self.proposed_patches: list[str] = []
|
|
35
|
+
self.proposed_edits: list[list[dict[str, Any]]] = []
|
|
36
|
+
|
|
37
|
+
def chat(self, messages: list[dict[str, Any]], *, tools: list[dict[str, Any]]) -> dict[str, Any]:
|
|
38
|
+
response = self.model.chat(messages, tools=tools)
|
|
39
|
+
self.model_calls += 1
|
|
40
|
+
self._record_proposed_patches(response)
|
|
41
|
+
self.total_duration_ns += _metric_int(response, "total_duration")
|
|
42
|
+
self.load_duration_ns += _metric_int(response, "load_duration")
|
|
43
|
+
self.prompt_tokens += _metric_int(response, "prompt_eval_count")
|
|
44
|
+
self.prompt_eval_duration_ns += _metric_int(response, "prompt_eval_duration")
|
|
45
|
+
self.eval_count += _metric_int(response, "eval_count")
|
|
46
|
+
self.eval_duration_ns += _metric_int(response, "eval_duration")
|
|
47
|
+
return response
|
|
48
|
+
|
|
49
|
+
def _record_proposed_patches(self, response: Mapping[str, Any]) -> None:
|
|
50
|
+
message = response.get("message")
|
|
51
|
+
if not isinstance(message, Mapping):
|
|
52
|
+
return
|
|
53
|
+
calls = message.get("tool_calls") or []
|
|
54
|
+
if not calls:
|
|
55
|
+
compatible_call = _decode_content_tool_call(message.get("content"))
|
|
56
|
+
if compatible_call is not None:
|
|
57
|
+
calls = [compatible_call]
|
|
58
|
+
for call in calls:
|
|
59
|
+
if not isinstance(call, Mapping):
|
|
60
|
+
continue
|
|
61
|
+
function = call.get("function")
|
|
62
|
+
if not isinstance(function, Mapping) or function.get("name") != "propose_patch":
|
|
63
|
+
continue
|
|
64
|
+
arguments = function.get("arguments")
|
|
65
|
+
if isinstance(arguments, str):
|
|
66
|
+
try:
|
|
67
|
+
arguments = json.loads(arguments)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
continue
|
|
70
|
+
if isinstance(arguments, Mapping):
|
|
71
|
+
if isinstance(arguments.get("patch"), str):
|
|
72
|
+
self.proposed_patches.append(arguments["patch"])
|
|
73
|
+
if isinstance(arguments.get("edits"), list):
|
|
74
|
+
self.proposed_edits.append(arguments["edits"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class BenchmarkCaseResult:
|
|
79
|
+
case_id: str
|
|
80
|
+
status: str
|
|
81
|
+
correct: bool
|
|
82
|
+
loop_reliable: bool
|
|
83
|
+
validation_valid: bool
|
|
84
|
+
patch_applied: bool
|
|
85
|
+
patch_source: str
|
|
86
|
+
patch_error: str
|
|
87
|
+
wall_time_ms: float
|
|
88
|
+
model_calls: int
|
|
89
|
+
tool_calls: int
|
|
90
|
+
total_duration_ns: int
|
|
91
|
+
load_duration_ns: int
|
|
92
|
+
prompt_tokens: int
|
|
93
|
+
prompt_eval_duration_ns: int
|
|
94
|
+
eval_count: int
|
|
95
|
+
eval_duration_ns: int
|
|
96
|
+
result: Mapping[str, Any]
|
|
97
|
+
|
|
98
|
+
def as_dict(self) -> dict[str, Any]:
|
|
99
|
+
prompt_eval_tps = (
|
|
100
|
+
round(self.prompt_tokens * 1e9 / self.prompt_eval_duration_ns, 2)
|
|
101
|
+
if self.prompt_eval_duration_ns > 0
|
|
102
|
+
else 0.0
|
|
103
|
+
)
|
|
104
|
+
eval_tps = (
|
|
105
|
+
round(self.eval_count * 1e9 / self.eval_duration_ns, 2)
|
|
106
|
+
if self.eval_duration_ns > 0
|
|
107
|
+
else 0.0
|
|
108
|
+
)
|
|
109
|
+
return {
|
|
110
|
+
"case_id": self.case_id,
|
|
111
|
+
"status": self.status,
|
|
112
|
+
"correct": self.correct,
|
|
113
|
+
"loop_reliable": self.loop_reliable,
|
|
114
|
+
"validation_valid": self.validation_valid,
|
|
115
|
+
"patch_applied": self.patch_applied,
|
|
116
|
+
"patch_source": self.patch_source,
|
|
117
|
+
"patch_error": self.patch_error,
|
|
118
|
+
"wall_time_ms": self.wall_time_ms,
|
|
119
|
+
"model_calls": self.model_calls,
|
|
120
|
+
"tool_calls": self.tool_calls,
|
|
121
|
+
"total_duration_ns": self.total_duration_ns,
|
|
122
|
+
"load_duration_ns": self.load_duration_ns,
|
|
123
|
+
"prompt_tokens": self.prompt_tokens,
|
|
124
|
+
"prompt_eval_duration_ns": self.prompt_eval_duration_ns,
|
|
125
|
+
"prompt_eval_tps": prompt_eval_tps,
|
|
126
|
+
"eval_count": self.eval_count,
|
|
127
|
+
"eval_duration_ns": self.eval_duration_ns,
|
|
128
|
+
"eval_tps": eval_tps,
|
|
129
|
+
"result": dict(self.result),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _decode_content_tool_call(content: Any) -> dict[str, Any] | None:
|
|
134
|
+
if not isinstance(content, str) or not content.strip():
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
payload = json.loads(content)
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
return None
|
|
140
|
+
if not isinstance(payload, Mapping):
|
|
141
|
+
return None
|
|
142
|
+
name = payload.get("name")
|
|
143
|
+
arguments = payload.get("arguments")
|
|
144
|
+
if not isinstance(name, str) or not name or not isinstance(arguments, (Mapping, str)):
|
|
145
|
+
return None
|
|
146
|
+
return {"function": {"name": name, "arguments": arguments}}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_case(
|
|
150
|
+
model: ChatModel,
|
|
151
|
+
case: BenchmarkCase,
|
|
152
|
+
*,
|
|
153
|
+
max_turns: int = 4,
|
|
154
|
+
) -> BenchmarkCaseResult:
|
|
155
|
+
"""Run one model against a disposable fixture and judge its proposal externally."""
|
|
156
|
+
|
|
157
|
+
instrumented = model if isinstance(model, InstrumentedModel) else InstrumentedModel(model)
|
|
158
|
+
with tempfile.TemporaryDirectory(prefix="local-benchmark-") as temp_dir:
|
|
159
|
+
workspace = Path(temp_dir)
|
|
160
|
+
for raw_path, content in case.fixture.items():
|
|
161
|
+
target = workspace / raw_path
|
|
162
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
target.write_text(content, encoding="utf-8", newline="")
|
|
164
|
+
|
|
165
|
+
started = perf_counter_ns()
|
|
166
|
+
result = Controller(instrumented, workspace, max_turns=max_turns).run(case.task)
|
|
167
|
+
wall_time_ms = (perf_counter_ns() - started) / 1_000_000
|
|
168
|
+
fallback_patch = instrumented.proposed_patches[-1] if instrumented.proposed_patches else ""
|
|
169
|
+
fallback_edits = instrumented.proposed_edits[-1] if instrumented.proposed_edits else None
|
|
170
|
+
patch_applied, correct, patch_error, patch_source = _judge_patch(
|
|
171
|
+
result,
|
|
172
|
+
case,
|
|
173
|
+
workspace,
|
|
174
|
+
fallback_patch=fallback_patch,
|
|
175
|
+
fallback_edits=fallback_edits,
|
|
176
|
+
)
|
|
177
|
+
tool_calls = sum(
|
|
178
|
+
1
|
|
179
|
+
for event in result.get("audit", [])
|
|
180
|
+
if isinstance(event, Mapping) and event.get("event") == "tool_call"
|
|
181
|
+
)
|
|
182
|
+
validation = result.get("validation")
|
|
183
|
+
validation_valid = isinstance(validation, Mapping) and validation.get("valid") is True
|
|
184
|
+
if patch_source == "tool_proposal":
|
|
185
|
+
validation_valid = _validate_patch_for_case(fallback_patch, case).valid
|
|
186
|
+
status = result.get("status") if isinstance(result.get("status"), str) else "failed"
|
|
187
|
+
loop_reliable = status == "accepted" and not _has_loop_error(result)
|
|
188
|
+
return BenchmarkCaseResult(
|
|
189
|
+
case_id=case.id,
|
|
190
|
+
status=status,
|
|
191
|
+
correct=correct,
|
|
192
|
+
loop_reliable=loop_reliable,
|
|
193
|
+
validation_valid=validation_valid,
|
|
194
|
+
patch_applied=patch_applied,
|
|
195
|
+
patch_source=patch_source,
|
|
196
|
+
patch_error=patch_error,
|
|
197
|
+
wall_time_ms=wall_time_ms,
|
|
198
|
+
model_calls=instrumented.model_calls,
|
|
199
|
+
tool_calls=tool_calls,
|
|
200
|
+
total_duration_ns=instrumented.total_duration_ns,
|
|
201
|
+
load_duration_ns=instrumented.load_duration_ns,
|
|
202
|
+
prompt_tokens=instrumented.prompt_tokens,
|
|
203
|
+
prompt_eval_duration_ns=instrumented.prompt_eval_duration_ns,
|
|
204
|
+
eval_count=instrumented.eval_count,
|
|
205
|
+
eval_duration_ns=instrumented.eval_duration_ns,
|
|
206
|
+
result=result,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def run_benchmark(
|
|
211
|
+
model_name: str,
|
|
212
|
+
model: ChatModel,
|
|
213
|
+
*,
|
|
214
|
+
cases: Sequence[BenchmarkCase] | None = None,
|
|
215
|
+
repeats: int = 1,
|
|
216
|
+
max_turns: int = 4,
|
|
217
|
+
) -> dict[str, Any]:
|
|
218
|
+
if repeats <= 0:
|
|
219
|
+
raise ValueError("repeats must be positive")
|
|
220
|
+
from ._summary import summarize_results
|
|
221
|
+
selected = tuple(cases or default_cases())
|
|
222
|
+
results = [
|
|
223
|
+
run_case(model, case, max_turns=max_turns)
|
|
224
|
+
for _ in range(repeats)
|
|
225
|
+
for case in selected
|
|
226
|
+
]
|
|
227
|
+
return {
|
|
228
|
+
"model": model_name,
|
|
229
|
+
"repeats": repeats,
|
|
230
|
+
"cases": [result.as_dict() for result in results],
|
|
231
|
+
"summary": summarize_results(model_name, results),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def write_artifact(path: str | Path, artifact: Mapping[str, Any]) -> None:
|
|
236
|
+
target = Path(path)
|
|
237
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
target.write_bytes(
|
|
239
|
+
(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _has_loop_error(result: Mapping[str, Any]) -> bool:
|
|
244
|
+
error = result.get("error")
|
|
245
|
+
if not isinstance(error, Mapping):
|
|
246
|
+
return False
|
|
247
|
+
return error.get("kind") in {
|
|
248
|
+
"duplicate_tool_call",
|
|
249
|
+
"max_turns",
|
|
250
|
+
"model_error",
|
|
251
|
+
"policy",
|
|
252
|
+
"invalid_json",
|
|
253
|
+
"invalid_response",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _metric_int(response: Mapping[str, Any], key: str) -> int:
|
|
258
|
+
value = response.get(key)
|
|
259
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Statistical helpers used by benchmark summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _percent(numerator: int, denominator: int) -> float:
|
|
9
|
+
return round(numerator * 100.0 / denominator, 2)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _wilson_score_interval(successes: int, total: int, confidence_z: float = 1.96) -> tuple[float, float]:
|
|
13
|
+
if total <= 0:
|
|
14
|
+
return 0.0, 0.0
|
|
15
|
+
p = successes / total
|
|
16
|
+
z = confidence_z
|
|
17
|
+
denominator = 1.0 + (z * z) / total
|
|
18
|
+
center = (p + (z * z) / (2.0 * total)) / denominator
|
|
19
|
+
margin = (z * math.sqrt((p * (1.0 - p) + (z * z) / (4.0 * total)) / total)) / denominator
|
|
20
|
+
low = max(0.0, center - margin)
|
|
21
|
+
high = min(1.0, center + margin)
|
|
22
|
+
return round(low * 100.0, 2), round(high * 100.0, 2)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Benchmark result summaries and failure taxonomy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from statistics import mean, median
|
|
6
|
+
from typing import Any, Mapping, Sequence
|
|
7
|
+
|
|
8
|
+
from ._runner import BenchmarkCaseResult
|
|
9
|
+
from ._stats import _percent, _wilson_score_interval
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def summarize_results(model_name: str, results: Sequence[BenchmarkCaseResult]) -> dict[str, Any]:
|
|
13
|
+
total = len(results)
|
|
14
|
+
if total == 0:
|
|
15
|
+
raise ValueError("cannot summarize an empty benchmark")
|
|
16
|
+
total_eval_tokens = sum(result.eval_count for result in results)
|
|
17
|
+
total_eval_duration_ns = sum(result.eval_duration_ns for result in results)
|
|
18
|
+
eval_tps = (
|
|
19
|
+
round(total_eval_tokens * 1_000_000_000.0 / total_eval_duration_ns, 2)
|
|
20
|
+
if total_eval_duration_ns > 0
|
|
21
|
+
else 0.0
|
|
22
|
+
)
|
|
23
|
+
total_prompt_tokens = sum(result.prompt_tokens for result in results)
|
|
24
|
+
total_prompt_eval_duration_ns = sum(result.prompt_eval_duration_ns for result in results)
|
|
25
|
+
prompt_eval_tps = (
|
|
26
|
+
round(total_prompt_tokens * 1_000_000_000.0 / total_prompt_eval_duration_ns, 2)
|
|
27
|
+
if total_prompt_eval_duration_ns > 0
|
|
28
|
+
else 0.0
|
|
29
|
+
)
|
|
30
|
+
correct_count = sum(result.correct for result in results)
|
|
31
|
+
loop_reliable_count = sum(result.loop_reliable for result in results)
|
|
32
|
+
patch_applied_count = sum(result.patch_applied for result in results)
|
|
33
|
+
return {
|
|
34
|
+
"model": model_name,
|
|
35
|
+
"cases": total,
|
|
36
|
+
"correctness_percent": _percent(correct_count, total),
|
|
37
|
+
"correctness_ci_95": _wilson_score_interval(correct_count, total),
|
|
38
|
+
"tool_loop_reliability_percent": _percent(loop_reliable_count, total),
|
|
39
|
+
"tool_loop_reliability_ci_95": _wilson_score_interval(loop_reliable_count, total),
|
|
40
|
+
"validation_percent": _percent(sum(result.validation_valid for result in results), total),
|
|
41
|
+
"patch_apply_percent": _percent(patch_applied_count, total),
|
|
42
|
+
"patch_apply_ci_95": _wilson_score_interval(patch_applied_count, total),
|
|
43
|
+
"error_categories": _categorize_errors(results),
|
|
44
|
+
"failure_taxonomy": categorize_failure_taxonomy(results),
|
|
45
|
+
"average_wall_time_ms": round(mean(result.wall_time_ms for result in results), 3),
|
|
46
|
+
"median_wall_time_ms": round(median(result.wall_time_ms for result in results), 3),
|
|
47
|
+
"model_calls": sum(result.model_calls for result in results),
|
|
48
|
+
"tool_calls": sum(result.tool_calls for result in results),
|
|
49
|
+
"total_duration_ms": round(sum(result.total_duration_ns for result in results) / 1_000_000, 3),
|
|
50
|
+
"load_duration_ms": round(sum(result.load_duration_ns for result in results) / 1_000_000, 3),
|
|
51
|
+
"prompt_tokens": total_prompt_tokens,
|
|
52
|
+
"eval_tokens": total_eval_tokens,
|
|
53
|
+
"prompt_eval_duration_ms": round(total_prompt_eval_duration_ns / 1_000_000, 3),
|
|
54
|
+
"eval_duration_ms": round(total_eval_duration_ns / 1_000_000, 3),
|
|
55
|
+
"eval_tokens_per_second": eval_tps,
|
|
56
|
+
"prompt_tokens_per_second": prompt_eval_tps,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _categorize_errors(results: Sequence[BenchmarkCaseResult]) -> dict[str, int]:
|
|
61
|
+
categories: dict[str, int] = {}
|
|
62
|
+
for r in results:
|
|
63
|
+
if r.correct and r.loop_reliable:
|
|
64
|
+
continue
|
|
65
|
+
err_msg = r.patch_error or ""
|
|
66
|
+
error_kind = (
|
|
67
|
+
r.result.get("error", {}).get("kind")
|
|
68
|
+
if isinstance(r.result.get("error"), Mapping)
|
|
69
|
+
else None
|
|
70
|
+
)
|
|
71
|
+
if "not line-aligned" in err_msg:
|
|
72
|
+
cat = "search_not_line_aligned"
|
|
73
|
+
elif "search block not found" in err_msg or "not found" in err_msg:
|
|
74
|
+
cat = "search_not_found"
|
|
75
|
+
elif "ambiguous" in err_msg:
|
|
76
|
+
cat = "search_ambiguous"
|
|
77
|
+
elif "git apply exited" in err_msg or "does not apply" in err_msg:
|
|
78
|
+
cat = "git_apply_failed"
|
|
79
|
+
elif "oracle mismatch" in err_msg:
|
|
80
|
+
cat = "oracle_mismatch"
|
|
81
|
+
elif error_kind:
|
|
82
|
+
cat = str(error_kind)
|
|
83
|
+
elif not r.patch_applied:
|
|
84
|
+
cat = "patch_not_applied"
|
|
85
|
+
else:
|
|
86
|
+
cat = "other"
|
|
87
|
+
categories[cat] = categories.get(cat, 0) + 1
|
|
88
|
+
return dict(sorted(categories.items()))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
CONTRACT_FRICTION_CATEGORIES: frozenset[str] = frozenset({
|
|
92
|
+
"search_not_line_aligned",
|
|
93
|
+
"search_not_found",
|
|
94
|
+
"search_ambiguous",
|
|
95
|
+
"git_apply_failed",
|
|
96
|
+
"patch_not_applied",
|
|
97
|
+
"invalid_json",
|
|
98
|
+
"invalid_response",
|
|
99
|
+
"duplicate_tool_call",
|
|
100
|
+
"preflight_rejected",
|
|
101
|
+
"context_limit",
|
|
102
|
+
"policy",
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
CAPABILITY_FAILURE_CATEGORIES: frozenset[str] = frozenset({
|
|
106
|
+
"oracle_mismatch",
|
|
107
|
+
"test_check_failed",
|
|
108
|
+
"check_failed",
|
|
109
|
+
"model_error",
|
|
110
|
+
"timeout",
|
|
111
|
+
"cancelled",
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def categorize_failure_taxonomy(results: Sequence[BenchmarkCaseResult]) -> dict[str, Any]:
|
|
116
|
+
categories = _categorize_errors(results)
|
|
117
|
+
contract_friction: dict[str, int] = {}
|
|
118
|
+
capability_failures: dict[str, int] = {}
|
|
119
|
+
other: dict[str, int] = {}
|
|
120
|
+
|
|
121
|
+
for cat, count in categories.items():
|
|
122
|
+
if cat in CONTRACT_FRICTION_CATEGORIES:
|
|
123
|
+
contract_friction[cat] = count
|
|
124
|
+
elif cat in CAPABILITY_FAILURE_CATEGORIES:
|
|
125
|
+
capability_failures[cat] = count
|
|
126
|
+
else:
|
|
127
|
+
other[cat] = count
|
|
128
|
+
|
|
129
|
+
friction_count = sum(contract_friction.values())
|
|
130
|
+
capability_count = sum(capability_failures.values())
|
|
131
|
+
other_count = sum(other.values())
|
|
132
|
+
total_failures = friction_count + capability_count + other_count
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
"total_failures": total_failures,
|
|
136
|
+
"contract_friction_count": friction_count,
|
|
137
|
+
"capability_failure_count": capability_count,
|
|
138
|
+
"friction_ratio": round(friction_count / total_failures, 2) if total_failures else 0.0,
|
|
139
|
+
"capability_ratio": round(capability_count / total_failures, 2) if total_failures else 0.0,
|
|
140
|
+
"contract_friction": dict(sorted(contract_friction.items())),
|
|
141
|
+
"capability_failures": dict(sorted(capability_failures.items())),
|
|
142
|
+
"other": dict(sorted(other.items())),
|
|
143
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Restricted child process for benchmark correctness oracles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import contextlib
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path as HostPath
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _RestrictedPath:
|
|
15
|
+
def __init__(self, root: str, relative: str = "") -> None:
|
|
16
|
+
self._root_text = str(HostPath(root).resolve())
|
|
17
|
+
self._relative = relative.replace("\\", "/").strip("/")
|
|
18
|
+
|
|
19
|
+
def __truediv__(self, child: str) -> "_RestrictedPath":
|
|
20
|
+
if not isinstance(child, str):
|
|
21
|
+
raise TypeError("restricted path component must be a string")
|
|
22
|
+
relative = "/".join(part for part in (self._relative, child) if part)
|
|
23
|
+
return _RestrictedPath(self._root_text, relative)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
return self._relative.rsplit("/", 1)[-1]
|
|
28
|
+
|
|
29
|
+
def _host_path(self) -> HostPath:
|
|
30
|
+
root = HostPath(self._root_text)
|
|
31
|
+
candidate = (root / self._relative).resolve()
|
|
32
|
+
try:
|
|
33
|
+
candidate.relative_to(root)
|
|
34
|
+
except ValueError as error:
|
|
35
|
+
raise PermissionError("oracle path escapes the disposable workspace") from error
|
|
36
|
+
return candidate
|
|
37
|
+
|
|
38
|
+
def read_text(self, encoding: str = "utf-8") -> str:
|
|
39
|
+
return self._host_path().read_text(encoding=encoding)
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
return str(self._host_path())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _restricted_open(file: Any, mode: str = "r", *args: Any, **kwargs: Any):
|
|
46
|
+
if not isinstance(file, _RestrictedPath):
|
|
47
|
+
raise PermissionError("oracle file access is limited to the disposable workspace")
|
|
48
|
+
if any(flag in mode for flag in ("w", "a", "+", "x")):
|
|
49
|
+
raise PermissionError("oracle workspace is read-only")
|
|
50
|
+
return open(file._host_path(), mode, *args, **kwargs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _restricted_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
|
54
|
+
if name != "json":
|
|
55
|
+
raise ImportError(f"oracle import is not allowlisted: {name}")
|
|
56
|
+
return builtins.__import__(name, *args, **kwargs)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _BoundedText(io.StringIO):
|
|
60
|
+
def __init__(self, limit: int = 4096) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
self._limit = limit
|
|
63
|
+
|
|
64
|
+
def write(self, value: str) -> int:
|
|
65
|
+
remaining = max(0, self._limit - len(self.getvalue()))
|
|
66
|
+
return super().write(value[:remaining])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fixture_builtins() -> dict[str, Any]:
|
|
70
|
+
allowed = {
|
|
71
|
+
key: getattr(builtins, key)
|
|
72
|
+
for key in (
|
|
73
|
+
"bool",
|
|
74
|
+
"dict",
|
|
75
|
+
"enumerate",
|
|
76
|
+
"Exception",
|
|
77
|
+
"float",
|
|
78
|
+
"ImportError",
|
|
79
|
+
"int",
|
|
80
|
+
"isinstance",
|
|
81
|
+
"len",
|
|
82
|
+
"list",
|
|
83
|
+
"range",
|
|
84
|
+
"set",
|
|
85
|
+
"str",
|
|
86
|
+
"sum",
|
|
87
|
+
"tuple",
|
|
88
|
+
"type",
|
|
89
|
+
"ValueError",
|
|
90
|
+
"abs",
|
|
91
|
+
"all",
|
|
92
|
+
"any",
|
|
93
|
+
"max",
|
|
94
|
+
"min",
|
|
95
|
+
"reversed",
|
|
96
|
+
"sorted",
|
|
97
|
+
"zip",
|
|
98
|
+
"__build_class__",
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
allowed["open"] = _restricted_open
|
|
102
|
+
allowed["__import__"] = _restricted_import
|
|
103
|
+
return allowed
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load_function(path: _RestrictedPath, name: str) -> Any:
|
|
107
|
+
source = path.read_text(encoding="utf-8")
|
|
108
|
+
namespace: dict[str, Any] = {
|
|
109
|
+
"__builtins__": _fixture_builtins(),
|
|
110
|
+
"__name__": "benchmark_fixture",
|
|
111
|
+
"__file__": str(path),
|
|
112
|
+
}
|
|
113
|
+
exec(compile(source, str(path), "exec"), namespace)
|
|
114
|
+
function = namespace.get(name)
|
|
115
|
+
if not callable(function):
|
|
116
|
+
raise ValueError(f"external oracle could not load {name} from {path.name}")
|
|
117
|
+
return function
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main() -> int:
|
|
121
|
+
payload = json.loads(sys.stdin.buffer.read().decode("utf-8"))
|
|
122
|
+
root = payload["workspace"]
|
|
123
|
+
oracle_name = payload["oracle_name"]
|
|
124
|
+
oracle_source = payload["oracle_source"]
|
|
125
|
+
if not isinstance(root, str) or not isinstance(oracle_name, str) or not isinstance(oracle_source, str):
|
|
126
|
+
raise ValueError("oracle worker payload is invalid")
|
|
127
|
+
workspace = _RestrictedPath(root)
|
|
128
|
+
namespace: dict[str, Any] = {
|
|
129
|
+
"__builtins__": {
|
|
130
|
+
key: getattr(builtins, key)
|
|
131
|
+
for key in (
|
|
132
|
+
"bool",
|
|
133
|
+
"dict",
|
|
134
|
+
"Exception",
|
|
135
|
+
"isinstance",
|
|
136
|
+
"str",
|
|
137
|
+
"tuple",
|
|
138
|
+
"ValueError",
|
|
139
|
+
"__build_class__",
|
|
140
|
+
)
|
|
141
|
+
},
|
|
142
|
+
"Path": _RestrictedPath,
|
|
143
|
+
"_load_function": _load_function,
|
|
144
|
+
}
|
|
145
|
+
namespace["__builtins__"]["__import__"] = _restricted_import
|
|
146
|
+
exec(compile(oracle_source, "<benchmark-oracle>", "exec"), namespace)
|
|
147
|
+
oracle = namespace.get(oracle_name)
|
|
148
|
+
if not callable(oracle):
|
|
149
|
+
raise ValueError(f"oracle function not found: {oracle_name}")
|
|
150
|
+
correct, detail = oracle(workspace)
|
|
151
|
+
if not isinstance(correct, bool) or not isinstance(detail, str):
|
|
152
|
+
raise ValueError("oracle must return (bool, str)")
|
|
153
|
+
print(json.dumps({"ok": True, "correct": correct, "detail": detail}, ensure_ascii=False))
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
output = _BoundedText()
|
|
159
|
+
error_output = _BoundedText()
|
|
160
|
+
try:
|
|
161
|
+
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error_output):
|
|
162
|
+
exit_code = main()
|
|
163
|
+
except BaseException as error: # worker boundary: serialize oracle failure
|
|
164
|
+
exit_code = 0
|
|
165
|
+
output = _BoundedText()
|
|
166
|
+
output.write(json.dumps({"ok": False, "error": str(error)}, ensure_ascii=False))
|
|
167
|
+
sys.stdout.write(output.getvalue())
|
|
168
|
+
sys.stderr.write(error_output.getvalue())
|
|
169
|
+
raise SystemExit(exit_code)
|