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,154 @@
|
|
|
1
|
+
"""VRAM-based worker-pool calibration for one Ollama model runtime.
|
|
2
|
+
|
|
3
|
+
The worker pool cannot promise physical parallelism that a single machine does
|
|
4
|
+
not have. This module answers one question: given a model and a VRAM budget,
|
|
5
|
+
how many concurrent worker slots can we run without over-subscribing memory?
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Mapping, Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OllamaRuntime(Protocol):
|
|
14
|
+
def loaded_models(self) -> Mapping[str, Any]: ...
|
|
15
|
+
|
|
16
|
+
def available_models(self) -> Mapping[str, Any]: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def calibrate_workers(
|
|
20
|
+
model_vram_bytes: int,
|
|
21
|
+
*,
|
|
22
|
+
vram_budget_bytes: int,
|
|
23
|
+
per_worker_context_bytes: int | None = None,
|
|
24
|
+
other_loaded_vram_bytes: int = 0,
|
|
25
|
+
hard_max: int = 4,
|
|
26
|
+
) -> int:
|
|
27
|
+
"""Return a bounded worker count using one model residency plus KV budget.
|
|
28
|
+
|
|
29
|
+
Ollama workers share the loaded model. ``model_vram_bytes`` is charged once;
|
|
30
|
+
each additional worker is charged an explicit ``per_worker_context_bytes``
|
|
31
|
+
estimate for its parallel context/KV cache, and already loaded other models
|
|
32
|
+
are charged through ``other_loaded_vram_bytes``. Without a measured context
|
|
33
|
+
estimate the safe answer is one worker rather than an invented copy count.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
if vram_budget_bytes <= 0:
|
|
37
|
+
raise ValueError("vram_budget_bytes must be positive")
|
|
38
|
+
if hard_max <= 0:
|
|
39
|
+
raise ValueError("hard_max must be positive")
|
|
40
|
+
if other_loaded_vram_bytes < 0:
|
|
41
|
+
raise ValueError("other_loaded_vram_bytes must be non-negative")
|
|
42
|
+
if model_vram_bytes <= 0 or per_worker_context_bytes is None:
|
|
43
|
+
return 1
|
|
44
|
+
if per_worker_context_bytes <= 0:
|
|
45
|
+
raise ValueError("per_worker_context_bytes must be positive")
|
|
46
|
+
available = vram_budget_bytes - other_loaded_vram_bytes - model_vram_bytes
|
|
47
|
+
if available <= 0:
|
|
48
|
+
return 1
|
|
49
|
+
by_context = 1 + available // per_worker_context_bytes
|
|
50
|
+
return max(1, min(hard_max, by_context))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def model_vram_bytes(client: OllamaRuntime, model_name: str) -> tuple[int, str]:
|
|
54
|
+
"""Estimate the VRAM footprint of ``model_name``.
|
|
55
|
+
|
|
56
|
+
Prefer the runtime-reported ``size_vram`` from ``/api/ps`` when the model is
|
|
57
|
+
loaded; otherwise return the model file ``size`` from ``/api/tags`` as a
|
|
58
|
+
diagnostic estimate only. Returns ``(bytes, source)``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
loaded = client.loaded_models()
|
|
62
|
+
footprint, source = _model_vram_from_loaded(loaded, model_name)
|
|
63
|
+
if source != "unknown":
|
|
64
|
+
return footprint, source
|
|
65
|
+
|
|
66
|
+
return _model_vram_from_tags(client.available_models(), model_name)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _model_vram_from_loaded(loaded: Any, model_name: str) -> tuple[int, str]:
|
|
70
|
+
raw_models = loaded.get("models") if isinstance(loaded, Mapping) else None
|
|
71
|
+
if isinstance(raw_models, list):
|
|
72
|
+
for model in raw_models:
|
|
73
|
+
if not isinstance(model, Mapping):
|
|
74
|
+
continue
|
|
75
|
+
name = model.get("name") or model.get("model")
|
|
76
|
+
if name == model_name and isinstance(model.get("size_vram"), int) and model["size_vram"] > 0:
|
|
77
|
+
return model["size_vram"], "ps.size_vram"
|
|
78
|
+
return 0, "unknown"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _model_vram_from_tags(available: Any, model_name: str) -> tuple[int, str]:
|
|
82
|
+
raw_tags = available.get("models") if isinstance(available, Mapping) else None
|
|
83
|
+
if isinstance(raw_tags, list):
|
|
84
|
+
for model in raw_tags:
|
|
85
|
+
if not isinstance(model, Mapping):
|
|
86
|
+
continue
|
|
87
|
+
if model.get("name") == model_name and isinstance(model.get("size"), int) and model["size"] > 0:
|
|
88
|
+
return model["size"], "tags.size_estimate"
|
|
89
|
+
|
|
90
|
+
return 0, "unknown"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def calibrate_for_model(
|
|
94
|
+
client: OllamaRuntime,
|
|
95
|
+
model_name: str,
|
|
96
|
+
*,
|
|
97
|
+
vram_budget_bytes: int,
|
|
98
|
+
per_worker_context_bytes: int | None = None,
|
|
99
|
+
hard_max: int = 4,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""Resolve runtime VRAM and derive a worker count conservatively.
|
|
102
|
+
|
|
103
|
+
A model-file size from ``/api/tags`` is diagnostic only. It is not used to
|
|
104
|
+
claim a multi-worker capacity because actual GPU residency depends on
|
|
105
|
+
offload, compute buffers, and runtime configuration.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
if vram_budget_bytes <= 0:
|
|
109
|
+
raise ValueError("vram_budget_bytes must be positive")
|
|
110
|
+
if hard_max <= 0:
|
|
111
|
+
raise ValueError("hard_max must be positive")
|
|
112
|
+
if per_worker_context_bytes is not None and per_worker_context_bytes <= 0:
|
|
113
|
+
raise ValueError("per_worker_context_bytes must be positive")
|
|
114
|
+
|
|
115
|
+
loaded = client.loaded_models()
|
|
116
|
+
footprint, source = _model_vram_from_loaded(loaded, model_name)
|
|
117
|
+
if source == "unknown":
|
|
118
|
+
footprint, source = _model_vram_from_tags(client.available_models(), model_name)
|
|
119
|
+
raw_models = loaded.get("models") if isinstance(loaded, Mapping) else None
|
|
120
|
+
other_loaded = 0
|
|
121
|
+
if isinstance(raw_models, list):
|
|
122
|
+
for model in raw_models:
|
|
123
|
+
if not isinstance(model, Mapping):
|
|
124
|
+
continue
|
|
125
|
+
name = model.get("name") or model.get("model")
|
|
126
|
+
size_vram = model.get("size_vram")
|
|
127
|
+
if name != model_name and isinstance(size_vram, int) and size_vram > 0:
|
|
128
|
+
other_loaded += size_vram
|
|
129
|
+
|
|
130
|
+
if source != "ps.size_vram":
|
|
131
|
+
workers = 1
|
|
132
|
+
basis = "runtime_vram_unconfirmed"
|
|
133
|
+
elif per_worker_context_bytes is None:
|
|
134
|
+
workers = 1
|
|
135
|
+
basis = "parallel_context_budget_missing"
|
|
136
|
+
else:
|
|
137
|
+
workers = calibrate_workers(
|
|
138
|
+
footprint,
|
|
139
|
+
vram_budget_bytes=vram_budget_bytes,
|
|
140
|
+
per_worker_context_bytes=per_worker_context_bytes,
|
|
141
|
+
other_loaded_vram_bytes=other_loaded,
|
|
142
|
+
hard_max=hard_max,
|
|
143
|
+
)
|
|
144
|
+
basis = "runtime_model_plus_parallel_context"
|
|
145
|
+
return {
|
|
146
|
+
"model": model_name,
|
|
147
|
+
"model_vram_bytes": footprint,
|
|
148
|
+
"source": source,
|
|
149
|
+
"other_loaded_vram_bytes": other_loaded,
|
|
150
|
+
"per_worker_context_bytes": per_worker_context_bytes,
|
|
151
|
+
"vram_budget_bytes": vram_budget_bytes,
|
|
152
|
+
"max_workers": workers,
|
|
153
|
+
"calibration_basis": basis,
|
|
154
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Multi-Dimensional Capability Ladder and Intelligence Gatekeeper (R15–R16)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from enum import IntEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
from .benchmark import (
|
|
13
|
+
BenchmarkCase,
|
|
14
|
+
ChatModel,
|
|
15
|
+
run_case,
|
|
16
|
+
_wilson_score_interval,
|
|
17
|
+
_percent,
|
|
18
|
+
)
|
|
19
|
+
from .task import TaskEnvelope
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Tier(IntEnum):
|
|
23
|
+
SYNTAX_TIER_0 = 0
|
|
24
|
+
ATOMIC_TIER_1 = 1
|
|
25
|
+
MULTI_HUNK_TIER_2 = 2
|
|
26
|
+
CROSS_FILE_TIER_3 = 3
|
|
27
|
+
ALGORITHMIC_TIER_4 = 4
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TIER_LABELS: Mapping[int, str] = {
|
|
31
|
+
Tier.SYNTAX_TIER_0: "Syntax & Formatting Repair",
|
|
32
|
+
Tier.ATOMIC_TIER_1: "Atomic Pure Functions",
|
|
33
|
+
Tier.MULTI_HUNK_TIER_2: "Single-File Multi-Hunk",
|
|
34
|
+
Tier.CROSS_FILE_TIER_3: "Cross-File Invariants",
|
|
35
|
+
Tier.ALGORITHMIC_TIER_4: "Algorithmic & Strict Constraints",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
GRANULARITY_BY_TIER: Mapping[int, str] = {
|
|
39
|
+
Tier.SYNTAX_TIER_0: "atomic_hunk",
|
|
40
|
+
Tier.ATOMIC_TIER_1: "function_level",
|
|
41
|
+
Tier.MULTI_HUNK_TIER_2: "file_level",
|
|
42
|
+
Tier.CROSS_FILE_TIER_3: "multi_file",
|
|
43
|
+
Tier.ALGORITHMIC_TIER_4: "multi_file",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CapabilityVector:
|
|
49
|
+
model: str
|
|
50
|
+
overall_tier: int
|
|
51
|
+
tier_label: str
|
|
52
|
+
confidence_95_ci: tuple[float, float]
|
|
53
|
+
correctness_percent: float
|
|
54
|
+
granularity_tolerance: str
|
|
55
|
+
turn_horizon: int
|
|
56
|
+
languages: tuple[str, ...]
|
|
57
|
+
tps_generation: float
|
|
58
|
+
tested_tiers: dict[int, dict[str, Any]] = field(default_factory=dict)
|
|
59
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
60
|
+
|
|
61
|
+
def as_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"model": self.model,
|
|
64
|
+
"overall_tier": int(self.overall_tier),
|
|
65
|
+
"tier_label": self.tier_label,
|
|
66
|
+
"confidence_95_ci": list(self.confidence_95_ci),
|
|
67
|
+
"correctness_percent": self.correctness_percent,
|
|
68
|
+
"granularity_tolerance": self.granularity_tolerance,
|
|
69
|
+
"turn_horizon": self.turn_horizon,
|
|
70
|
+
"languages": list(self.languages),
|
|
71
|
+
"tps_generation": self.tps_generation,
|
|
72
|
+
"tested_tiers": self.tested_tiers,
|
|
73
|
+
"timestamp": self.timestamp,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _syntax_colon_oracle(workspace: Path) -> tuple[bool, str]:
|
|
78
|
+
try:
|
|
79
|
+
fn = _load_function(workspace / "src/syntax_add.py", "add")
|
|
80
|
+
if fn(2, 3) != 5:
|
|
81
|
+
return False, "external oracle mismatch: add(2, 3) != 5"
|
|
82
|
+
return True, ""
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
return False, f"syntax error remains: {exc}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _syntax_typo_oracle(workspace: Path) -> tuple[bool, str]:
|
|
88
|
+
try:
|
|
89
|
+
fn = _load_function(workspace / "src/square.py", "square")
|
|
90
|
+
if fn(4) != 16:
|
|
91
|
+
return False, "external oracle mismatch: square(4) != 16"
|
|
92
|
+
return True, ""
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
return False, f"error in square: {exc}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _multihunk_divide_oracle(workspace: Path) -> tuple[bool, str]:
|
|
98
|
+
try:
|
|
99
|
+
calc = _load_function(workspace / "src/calc.py", "calculate_ratio")
|
|
100
|
+
if calc(10, 2) != 5.0 or calc(10, 0) != 0.0:
|
|
101
|
+
return False, "external oracle mismatch: ratio calculation incorrect"
|
|
102
|
+
return True, ""
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
return False, f"calc error: {exc}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _crossfile_oracle(workspace: Path) -> tuple[bool, str]:
|
|
108
|
+
try:
|
|
109
|
+
service = _load_function(workspace / "src/service.py", "render_user")
|
|
110
|
+
res = service("Alice", 30)
|
|
111
|
+
if res != "User: Alice, Age: 30":
|
|
112
|
+
return False, f"expected 'User: Alice, Age: 30', got {res!r}"
|
|
113
|
+
return True, ""
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
return False, f"crossfile error: {exc}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _algo_lru_oracle(workspace: Path) -> tuple[bool, str]:
|
|
119
|
+
try:
|
|
120
|
+
cache_cls = _load_function(workspace / "src/lru.py", "SimpleLRU")
|
|
121
|
+
c = cache_cls(capacity=2)
|
|
122
|
+
c.put("a", 1)
|
|
123
|
+
c.put("b", 2)
|
|
124
|
+
if c.get("a") != 1:
|
|
125
|
+
return False, "LRU get failed"
|
|
126
|
+
c.put("c", 3) # evicts b
|
|
127
|
+
if c.get("b") is not None or c.get("a") != 1 or c.get("c") != 3:
|
|
128
|
+
return False, "LRU eviction failed"
|
|
129
|
+
return True, ""
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
return False, f"algo error: {exc}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def ladder_cases() -> dict[Tier, tuple[BenchmarkCase, ...]]:
|
|
136
|
+
"""Return benchmark test cases partitioned by difficulty tier."""
|
|
137
|
+
from .benchmark import (
|
|
138
|
+
_unique_oracle,
|
|
139
|
+
_limit_oracle,
|
|
140
|
+
_count_positives_oracle,
|
|
141
|
+
_abs_sum_oracle,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
t0_cases = (
|
|
145
|
+
BenchmarkCase(
|
|
146
|
+
id="syntax-missing-colon",
|
|
147
|
+
task=TaskEnvelope(
|
|
148
|
+
id="syntax-missing-colon",
|
|
149
|
+
goal="исправить синтаксическую ошибку отсутствующего двоеточия",
|
|
150
|
+
files=("src/syntax_add.py",),
|
|
151
|
+
context="В определении функции пропущено двоеточие.",
|
|
152
|
+
constraints=("не менять имя функции",),
|
|
153
|
+
acceptance=("синтаксис валиден", "функция складывает числа"),
|
|
154
|
+
),
|
|
155
|
+
fixture={"src/syntax_add.py": "def add(a, b)\n return a + b\n"},
|
|
156
|
+
expected_files={"src/syntax_add.py": "def add(a, b):\n return a + b\n"},
|
|
157
|
+
oracle=_syntax_colon_oracle,
|
|
158
|
+
),
|
|
159
|
+
BenchmarkCase(
|
|
160
|
+
id="syntax-typo-var",
|
|
161
|
+
task=TaskEnvelope(
|
|
162
|
+
id="syntax-typo-var",
|
|
163
|
+
goal="исправить опечатку в имени переменной",
|
|
164
|
+
files=("src/square.py",),
|
|
165
|
+
context="Переменная y не определена, нужно использовать x.",
|
|
166
|
+
constraints=("не менять имя функции",),
|
|
167
|
+
acceptance=("square(4) == 16",),
|
|
168
|
+
),
|
|
169
|
+
fixture={"src/square.py": "def square(x):\n return y * x\n"},
|
|
170
|
+
expected_files={"src/square.py": "def square(x):\n return x * x\n"},
|
|
171
|
+
oracle=_syntax_typo_oracle,
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
t1_cases = (
|
|
176
|
+
BenchmarkCase(
|
|
177
|
+
id="unique-preserve-order",
|
|
178
|
+
task=TaskEnvelope(
|
|
179
|
+
id="unique-preserve-order",
|
|
180
|
+
goal="убрать сортировку из unique и сохранить порядок первого появления",
|
|
181
|
+
files=("src/unique.py",),
|
|
182
|
+
context="Функция должна удалить повторы, но не менять порядок входных значений.",
|
|
183
|
+
constraints=("не менять публичную сигнатуру", "не добавлять зависимости"),
|
|
184
|
+
acceptance=("повторы удаляются", "порядок первого появления сохраняется"),
|
|
185
|
+
),
|
|
186
|
+
fixture={"src/unique.py": "def unique(values):\n return sorted(set(values))\n"},
|
|
187
|
+
expected_files={"src/unique.py": "def unique(values):\n return list(dict.fromkeys(values))\n"},
|
|
188
|
+
oracle=_unique_oracle,
|
|
189
|
+
),
|
|
190
|
+
BenchmarkCase(
|
|
191
|
+
id="limit-inclusive",
|
|
192
|
+
task=TaskEnvelope(
|
|
193
|
+
id="limit-inclusive",
|
|
194
|
+
goal="исправить off-by-one и вернуть ровно limit элементов",
|
|
195
|
+
files=("src/window.py",),
|
|
196
|
+
context="При положительном limit срез должен включать элемент с индексом limit-1.",
|
|
197
|
+
constraints=("изменить только выражение среза",),
|
|
198
|
+
acceptance=("limit=3 возвращает первые три элемента",),
|
|
199
|
+
),
|
|
200
|
+
fixture={"src/window.py": "def take(values, limit):\n return values[: limit - 1]\n"},
|
|
201
|
+
expected_files={"src/window.py": "def take(values, limit):\n return values[:limit]\n"},
|
|
202
|
+
oracle=_limit_oracle,
|
|
203
|
+
),
|
|
204
|
+
BenchmarkCase(
|
|
205
|
+
id="count-positives",
|
|
206
|
+
task=TaskEnvelope(
|
|
207
|
+
id="count-positives",
|
|
208
|
+
goal="посчитать количество положительных элементов",
|
|
209
|
+
files=("src/count.py",),
|
|
210
|
+
context="Считать нужно только элементы строго больше нуля.",
|
|
211
|
+
constraints=("сохранить имя функции", "не добавлять зависимости"),
|
|
212
|
+
acceptance=("счётчик учитывает только положительные элементы",),
|
|
213
|
+
),
|
|
214
|
+
fixture={"src/count.py": "def count_positives(values):\n return len(values)\n"},
|
|
215
|
+
expected_files={"src/count.py": "def count_positives(values):\n return sum(1 for value in values if value > 0)\n"},
|
|
216
|
+
oracle=_count_positives_oracle,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
t2_cases = (
|
|
221
|
+
BenchmarkCase(
|
|
222
|
+
id="multihunk-safe-divide",
|
|
223
|
+
task=TaskEnvelope(
|
|
224
|
+
id="multihunk-safe-divide",
|
|
225
|
+
goal="добавить безопасное деление с обработкой нуля в хелпере и вызове",
|
|
226
|
+
files=("src/calc.py",),
|
|
227
|
+
context="Обнови safe_div для возврата 0.0 при b=0 и используй safe_div в calculate_ratio.",
|
|
228
|
+
constraints=("не менять сигнатуру calculate_ratio",),
|
|
229
|
+
acceptance=("calculate_ratio(10, 0) == 0.0", "calculate_ratio(10, 2) == 5.0"),
|
|
230
|
+
),
|
|
231
|
+
fixture={
|
|
232
|
+
"src/calc.py": (
|
|
233
|
+
"def safe_div(a, b):\n"
|
|
234
|
+
" return a / b\n\n"
|
|
235
|
+
"def calculate_ratio(a, b):\n"
|
|
236
|
+
" return a / b\n"
|
|
237
|
+
)
|
|
238
|
+
},
|
|
239
|
+
expected_files={
|
|
240
|
+
"src/calc.py": (
|
|
241
|
+
"def safe_div(a, b):\n"
|
|
242
|
+
" return a / b if b != 0 else 0.0\n\n"
|
|
243
|
+
"def calculate_ratio(a, b):\n"
|
|
244
|
+
" return safe_div(a, b)\n"
|
|
245
|
+
)
|
|
246
|
+
},
|
|
247
|
+
oracle=_multihunk_divide_oracle,
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
t3_cases = (
|
|
252
|
+
BenchmarkCase(
|
|
253
|
+
id="crossfile-signature-update",
|
|
254
|
+
task=TaskEnvelope(
|
|
255
|
+
id="crossfile-signature-update",
|
|
256
|
+
goal="обновить класс User и функцию рендеринга для поддержки age",
|
|
257
|
+
files=("src/models.py", "src/service.py"),
|
|
258
|
+
context="Добавь поле age в User и включи его в строку render_user: 'User: {name}, Age: {age}'.",
|
|
259
|
+
constraints=("обновить оба файла согласованно",),
|
|
260
|
+
acceptance=("render_user('Alice', 30) возвращает 'User: Alice, Age: 30'",),
|
|
261
|
+
),
|
|
262
|
+
fixture={
|
|
263
|
+
"src/models.py": "class User:\n def __init__(self, name):\n self.name = name\n",
|
|
264
|
+
"src/service.py": "from .models import User\n\ndef render_user(name, age=0):\n u = User(name)\n return f'User: {u.name}'\n",
|
|
265
|
+
},
|
|
266
|
+
expected_files={
|
|
267
|
+
"src/models.py": "class User:\n def __init__(self, name, age=0):\n self.name = name\n self.age = age\n",
|
|
268
|
+
"src/service.py": "from .models import User\n\ndef render_user(name, age=0):\n u = User(name, age)\n return f'User: {u.name}, Age: {u.age}'\n",
|
|
269
|
+
},
|
|
270
|
+
oracle=_crossfile_oracle,
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
t4_cases = (
|
|
275
|
+
BenchmarkCase(
|
|
276
|
+
id="algo-lru-cache",
|
|
277
|
+
task=TaskEnvelope(
|
|
278
|
+
id="algo-lru-cache",
|
|
279
|
+
goal="реализовать алгоритм вытеснения LRU кэша заданной емкости",
|
|
280
|
+
files=("src/lru.py",),
|
|
281
|
+
context="Класс SimpleLRU с методами get(key) и put(key, val), вытесняющий наименее используемый элемент при превышении capacity.",
|
|
282
|
+
constraints=("использовать dict или OrderedDict",),
|
|
283
|
+
acceptance=("get возвращает значение", "старые элементы вытесняются"),
|
|
284
|
+
),
|
|
285
|
+
fixture={
|
|
286
|
+
"src/lru.py": (
|
|
287
|
+
"class SimpleLRU:\n"
|
|
288
|
+
" def __init__(self, capacity=2):\n"
|
|
289
|
+
" self.capacity = capacity\n"
|
|
290
|
+
" self.items = {}\n\n"
|
|
291
|
+
" def get(self, key):\n"
|
|
292
|
+
" return self.items.get(key)\n\n"
|
|
293
|
+
" def put(self, key, val):\n"
|
|
294
|
+
" self.items[key] = val\n"
|
|
295
|
+
)
|
|
296
|
+
},
|
|
297
|
+
expected_files={
|
|
298
|
+
"src/lru.py": (
|
|
299
|
+
"class SimpleLRU:\n"
|
|
300
|
+
" def __init__(self, capacity=2):\n"
|
|
301
|
+
" self.capacity = capacity\n"
|
|
302
|
+
" self.items = {}\n\n"
|
|
303
|
+
" def get(self, key):\n"
|
|
304
|
+
" if key not in self.items:\n"
|
|
305
|
+
" return None\n"
|
|
306
|
+
" val = self.items.pop(key)\n"
|
|
307
|
+
" self.items[key] = val\n"
|
|
308
|
+
" return val\n\n"
|
|
309
|
+
" def put(self, key, val):\n"
|
|
310
|
+
" if key in self.items:\n"
|
|
311
|
+
" self.items.pop(key)\n"
|
|
312
|
+
" elif len(self.items) >= self.capacity:\n"
|
|
313
|
+
" first_key = next(iter(self.items))\n"
|
|
314
|
+
" del self.items[first_key]\n"
|
|
315
|
+
" self.items[key] = val\n"
|
|
316
|
+
)
|
|
317
|
+
},
|
|
318
|
+
oracle=_algo_lru_oracle,
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
Tier.SYNTAX_TIER_0: t0_cases,
|
|
324
|
+
Tier.ATOMIC_TIER_1: t1_cases,
|
|
325
|
+
Tier.MULTI_HUNK_TIER_2: t2_cases,
|
|
326
|
+
Tier.CROSS_FILE_TIER_3: t3_cases,
|
|
327
|
+
Tier.ALGORITHMIC_TIER_4: t4_cases,
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class CapabilityLadder:
|
|
332
|
+
"""Evaluates a model across progressive difficulty tiers with adaptive early exit."""
|
|
333
|
+
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
cases_by_tier: Mapping[Tier, Sequence[BenchmarkCase]] | None = None,
|
|
337
|
+
*,
|
|
338
|
+
threshold: float = 0.60,
|
|
339
|
+
) -> None:
|
|
340
|
+
self.cases_by_tier = cases_by_tier or ladder_cases()
|
|
341
|
+
self.threshold = threshold
|
|
342
|
+
|
|
343
|
+
def evaluate(
|
|
344
|
+
self,
|
|
345
|
+
model_name: str,
|
|
346
|
+
model: ChatModel,
|
|
347
|
+
*,
|
|
348
|
+
max_turns: int = 4,
|
|
349
|
+
) -> CapabilityVector:
|
|
350
|
+
tested_tiers: dict[int, dict[str, Any]] = {}
|
|
351
|
+
highest_passing_tier = -1
|
|
352
|
+
total_eval_tokens = 0
|
|
353
|
+
total_eval_duration_ns = 0
|
|
354
|
+
total_turns = 0
|
|
355
|
+
total_cases_run = 0
|
|
356
|
+
all_passed_count = 0
|
|
357
|
+
|
|
358
|
+
for tier in (
|
|
359
|
+
Tier.SYNTAX_TIER_0,
|
|
360
|
+
Tier.ATOMIC_TIER_1,
|
|
361
|
+
Tier.MULTI_HUNK_TIER_2,
|
|
362
|
+
Tier.CROSS_FILE_TIER_3,
|
|
363
|
+
Tier.ALGORITHMIC_TIER_4,
|
|
364
|
+
):
|
|
365
|
+
cases = self.cases_by_tier.get(tier, ())
|
|
366
|
+
if not cases:
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
results = [run_case(model, case, max_turns=max_turns) for case in cases]
|
|
370
|
+
correct_count = sum(r.correct for r in results)
|
|
371
|
+
tier_total = len(results)
|
|
372
|
+
tier_score = correct_count / tier_total if tier_total > 0 else 0.0
|
|
373
|
+
|
|
374
|
+
total_cases_run += tier_total
|
|
375
|
+
all_passed_count += correct_count
|
|
376
|
+
total_eval_tokens += sum(r.eval_count for r in results)
|
|
377
|
+
total_eval_duration_ns += sum(r.eval_duration_ns for r in results)
|
|
378
|
+
total_turns += sum(r.model_calls for r in results)
|
|
379
|
+
|
|
380
|
+
passed = tier_score >= self.threshold
|
|
381
|
+
tested_tiers[int(tier)] = {
|
|
382
|
+
"status": "passed" if passed else "failed",
|
|
383
|
+
"score_percent": round(tier_score * 100.0, 2),
|
|
384
|
+
"passed_cases": correct_count,
|
|
385
|
+
"total_cases": tier_total,
|
|
386
|
+
"label": TIER_LABELS.get(int(tier), f"Tier {tier}"),
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if passed:
|
|
390
|
+
highest_passing_tier = int(tier)
|
|
391
|
+
else:
|
|
392
|
+
# Adaptive early exit: do not test higher tiers
|
|
393
|
+
break
|
|
394
|
+
|
|
395
|
+
overall_tier = max(0, highest_passing_tier) if highest_passing_tier >= 0 else 0
|
|
396
|
+
tier_label = TIER_LABELS.get(overall_tier, "Unrated")
|
|
397
|
+
ci_95 = _wilson_score_interval(all_passed_count, total_cases_run)
|
|
398
|
+
correctness_pct = _percent(all_passed_count, total_cases_run) if total_cases_run > 0 else 0.0
|
|
399
|
+
|
|
400
|
+
eval_tps = (
|
|
401
|
+
round(total_eval_tokens * 1e9 / total_eval_duration_ns, 2)
|
|
402
|
+
if total_eval_duration_ns > 0
|
|
403
|
+
else 0.0
|
|
404
|
+
)
|
|
405
|
+
avg_turns = round(total_turns / total_cases_run) if total_cases_run > 0 else max_turns
|
|
406
|
+
granularity = GRANULARITY_BY_TIER.get(overall_tier, "atomic_hunk")
|
|
407
|
+
|
|
408
|
+
return CapabilityVector(
|
|
409
|
+
model=model_name,
|
|
410
|
+
overall_tier=overall_tier,
|
|
411
|
+
tier_label=tier_label,
|
|
412
|
+
confidence_95_ci=ci_95,
|
|
413
|
+
correctness_percent=correctness_pct,
|
|
414
|
+
granularity_tolerance=granularity,
|
|
415
|
+
turn_horizon=max(1, avg_turns),
|
|
416
|
+
languages=("python",),
|
|
417
|
+
tps_generation=eval_tps,
|
|
418
|
+
tested_tiers=tested_tiers,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def check_capability_overload(
|
|
423
|
+
task: TaskEnvelope, profile: CapabilityVector | None
|
|
424
|
+
) -> tuple[bool, str | None, str | None]:
|
|
425
|
+
"""Check if task complexity exceeds verified capability tier bounds."""
|
|
426
|
+
if profile is None:
|
|
427
|
+
return False, None, None
|
|
428
|
+
|
|
429
|
+
file_count = len(task.files)
|
|
430
|
+
if file_count > 1 and profile.overall_tier < Tier.CROSS_FILE_TIER_3:
|
|
431
|
+
reason = (
|
|
432
|
+
f"CAPABILITY_OVERLOAD: task targets {file_count} files, exceeding verified model tier "
|
|
433
|
+
f"{profile.overall_tier} ({profile.tier_label}) which supports single-file tasks."
|
|
434
|
+
)
|
|
435
|
+
prescription = (
|
|
436
|
+
f"Decompose the multi-file task into single-file atomic tasks using `local-agent decompose --task ...` "
|
|
437
|
+
f"or reduce the allowlist files to <= 1 file."
|
|
438
|
+
)
|
|
439
|
+
return True, reason, prescription
|
|
440
|
+
|
|
441
|
+
if file_count > 1 and profile.granularity_tolerance != "multi_file":
|
|
442
|
+
reason = (
|
|
443
|
+
f"CAPABILITY_OVERLOAD: model granularity tolerance is '{profile.granularity_tolerance}', "
|
|
444
|
+
f"which does not support multi-file modifications."
|
|
445
|
+
)
|
|
446
|
+
prescription = "Decompose task with `local-agent decompose`."
|
|
447
|
+
return True, reason, prescription
|
|
448
|
+
|
|
449
|
+
return False, None, None
|
|
450
|
+
|