misterdev 0.2.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.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- misterdev-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Harvest OpenRouter's rotating free models.
|
|
2
|
+
|
|
3
|
+
OpenRouter publishes a public model list whose ``:free`` entries (zero prompt
|
|
4
|
+
and completion price) rotate over time. This fetches and day-caches that list
|
|
5
|
+
so the selection policy can offer free models as cheap-tier candidates.
|
|
6
|
+
|
|
7
|
+
Reliability is preserved upstream of this module: a free model only ever
|
|
8
|
+
appears on non-final attempts, is subject to the same proven-first-try gate as
|
|
9
|
+
any cheap model, and (when a paid failover is configured) degrades to the paid
|
|
10
|
+
provider on rate-limit/outage. So harvesting can only reduce cost, never lower
|
|
11
|
+
the quality floor enforced by the validation gates.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from misterdev.logging_setup import setup_logger
|
|
19
|
+
|
|
20
|
+
logger = setup_logger(__name__)
|
|
21
|
+
|
|
22
|
+
_MODELS_URL = "https://openrouter.ai/api/v1/models"
|
|
23
|
+
_DAY_SECONDS = 24 * 3600
|
|
24
|
+
|
|
25
|
+
# Per-process cache of fetched list endpoints, keyed by URL. Free-model
|
|
26
|
+
# harvesting, the model catalog, and each failover client's catalog all hit the
|
|
27
|
+
# same /models endpoint; this collapses those into one network round-trip per
|
|
28
|
+
# URL per run. Successful results only (failures re-raise without caching).
|
|
29
|
+
_FETCH_CACHE: Dict[str, list] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _http_fetch(url: str = _MODELS_URL) -> list:
|
|
33
|
+
"""Fetch and return the ``data`` array from an OpenRouter list endpoint.
|
|
34
|
+
|
|
35
|
+
Shared by free-model harvesting, the model catalog, and embedding-model
|
|
36
|
+
discovery — the network boundary lives in exactly one place.
|
|
37
|
+
"""
|
|
38
|
+
if url in _FETCH_CACHE:
|
|
39
|
+
return _FETCH_CACHE[url]
|
|
40
|
+
import urllib.request
|
|
41
|
+
|
|
42
|
+
req = urllib.request.Request(url, headers={"User-Agent": "misterdev"})
|
|
43
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
44
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
45
|
+
data = payload.get("data", []) if isinstance(payload, dict) else []
|
|
46
|
+
_FETCH_CACHE[url] = data
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_free(model: dict) -> bool:
|
|
51
|
+
pricing = model.get("pricing") or {}
|
|
52
|
+
|
|
53
|
+
def _zero(value) -> bool:
|
|
54
|
+
try:
|
|
55
|
+
return float(value) == 0.0
|
|
56
|
+
except (TypeError, ValueError):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
# A model is free only when both prompt and completion are explicitly zero.
|
|
60
|
+
return (
|
|
61
|
+
"prompt" in pricing
|
|
62
|
+
and "completion" in pricing
|
|
63
|
+
and _zero(pricing.get("prompt"))
|
|
64
|
+
and _zero(pricing.get("completion"))
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FreeModelCache:
|
|
69
|
+
"""Day-cached list of free OpenRouter model ids, backed by a JSON file."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, path: Path, fetcher: Optional[Callable[[], list]] = None):
|
|
72
|
+
self.path = Path(path)
|
|
73
|
+
self._fetcher = fetcher or _http_fetch
|
|
74
|
+
|
|
75
|
+
def _load(self) -> Optional[dict]:
|
|
76
|
+
if not self.path.exists():
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
80
|
+
return data if isinstance(data, dict) else None
|
|
81
|
+
except (json.JSONDecodeError, OSError):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def _save(self, data: dict) -> None:
|
|
85
|
+
from misterdev.utils.file_utils import atomic_write_json
|
|
86
|
+
|
|
87
|
+
atomic_write_json(self.path, data, indent=2, sort_keys=True)
|
|
88
|
+
|
|
89
|
+
def get(self, now: float, max_age_seconds: int = _DAY_SECONDS) -> List[str]:
|
|
90
|
+
"""Return current free model ids, refetching when the cache is stale.
|
|
91
|
+
|
|
92
|
+
On a fetch failure, falls back to the last cached list (possibly empty)
|
|
93
|
+
so a transient network problem never blocks a build.
|
|
94
|
+
"""
|
|
95
|
+
cached = self._load()
|
|
96
|
+
if cached and (now - cached.get("fetched_at", 0)) < max_age_seconds:
|
|
97
|
+
return list(cached.get("models", []))
|
|
98
|
+
try:
|
|
99
|
+
raw = self._fetcher()
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.warning(f"Free-model fetch failed, using cached list: {e}")
|
|
102
|
+
return list(cached.get("models", [])) if cached else []
|
|
103
|
+
free = sorted(
|
|
104
|
+
m["id"] for m in raw if isinstance(m, dict) and m.get("id") and _is_free(m)
|
|
105
|
+
)
|
|
106
|
+
self._save({"fetched_at": now, "models": free})
|
|
107
|
+
logger.info(f"Harvested {len(free)} free OpenRouter models")
|
|
108
|
+
return free
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Content-addressed memoization of gate-passing LLM outputs.
|
|
2
|
+
|
|
3
|
+
Only outputs that cleared the validation gates are stored, keyed by a hash of
|
|
4
|
+
the full prompt (system + user). Because the executor's prompt already embeds
|
|
5
|
+
the current file contents, interface contracts, and strategy, the key changes
|
|
6
|
+
whenever any input that affects the correct output changes — so a stale edit
|
|
7
|
+
auto-invalidates. A cache hit is still re-applied through the gates, so even a
|
|
8
|
+
collision or staleness can only cost a wasted gate run, never ship bad code.
|
|
9
|
+
|
|
10
|
+
The store is model-agnostic on purpose: a cheap (or free) model's successful
|
|
11
|
+
result is reusable regardless of which model would be picked next time.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from misterdev.logging_setup import setup_logger
|
|
21
|
+
|
|
22
|
+
logger = setup_logger(__name__)
|
|
23
|
+
|
|
24
|
+
# Cap on stored entries. One file per entry would otherwise grow without bound
|
|
25
|
+
# across many builds; past the cap the oldest entries (by mtime) are evicted.
|
|
26
|
+
# A re-applied hit only ever saves a gate run, so dropping cold entries is safe.
|
|
27
|
+
DEFAULT_MAX_ENTRIES = 2000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LLMCache:
|
|
31
|
+
"""One JSON file per cache entry under a directory, bounded by mtime LRU."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, dir_path: Path, max_entries: int = DEFAULT_MAX_ENTRIES):
|
|
34
|
+
self.dir = Path(dir_path)
|
|
35
|
+
self.max_entries = max_entries
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _key(system_prompt: str, prompt: str) -> str:
|
|
39
|
+
h = hashlib.sha256()
|
|
40
|
+
h.update((system_prompt or "").encode("utf-8"))
|
|
41
|
+
h.update(b"\x00")
|
|
42
|
+
h.update((prompt or "").encode("utf-8"))
|
|
43
|
+
return h.hexdigest()
|
|
44
|
+
|
|
45
|
+
def _path(self, key: str) -> Path:
|
|
46
|
+
return self.dir / f"{key}.json"
|
|
47
|
+
|
|
48
|
+
def get(self, system_prompt: str, prompt: str) -> Optional[str]:
|
|
49
|
+
"""Return a cached output for this prompt, or None on miss."""
|
|
50
|
+
path = self._path(self._key(system_prompt, prompt))
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
56
|
+
logger.warning(f"Ignoring unreadable cache entry {path.name}: {e}")
|
|
57
|
+
return None
|
|
58
|
+
output = data.get("output")
|
|
59
|
+
return output if isinstance(output, str) else None
|
|
60
|
+
|
|
61
|
+
def put(
|
|
62
|
+
self,
|
|
63
|
+
system_prompt: str,
|
|
64
|
+
prompt: str,
|
|
65
|
+
output: str,
|
|
66
|
+
model: str = "",
|
|
67
|
+
timestamp: float = 0.0,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Store a gate-passing output (atomic write)."""
|
|
70
|
+
from misterdev.utils.file_utils import atomic_write_json
|
|
71
|
+
|
|
72
|
+
key = self._key(system_prompt, prompt)
|
|
73
|
+
path = self._path(key)
|
|
74
|
+
atomic_write_json(
|
|
75
|
+
path,
|
|
76
|
+
{"output": output, "model": model, "created_at": timestamp, "key": key},
|
|
77
|
+
indent=2,
|
|
78
|
+
)
|
|
79
|
+
self._evict_if_needed()
|
|
80
|
+
|
|
81
|
+
def _evict_if_needed(self) -> None:
|
|
82
|
+
"""Drop the oldest entries (by mtime) once the cap is exceeded.
|
|
83
|
+
|
|
84
|
+
Best-effort: any filesystem error degrades to leaving the cache as-is
|
|
85
|
+
rather than raising into the build, like the rest of this module.
|
|
86
|
+
"""
|
|
87
|
+
if self.max_entries <= 0:
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
entries = [
|
|
91
|
+
(e.stat().st_mtime, e.path)
|
|
92
|
+
for e in os.scandir(self.dir)
|
|
93
|
+
if e.name.endswith(".json") and e.is_file()
|
|
94
|
+
]
|
|
95
|
+
except OSError:
|
|
96
|
+
return
|
|
97
|
+
excess = len(entries) - self.max_entries
|
|
98
|
+
if excess <= 0:
|
|
99
|
+
return
|
|
100
|
+
entries.sort(key=lambda t: t[0])
|
|
101
|
+
for _mtime, victim in entries[:excess]:
|
|
102
|
+
try:
|
|
103
|
+
os.remove(victim)
|
|
104
|
+
except OSError:
|
|
105
|
+
continue
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Per-model capability profiles from OpenRouter's model list.
|
|
2
|
+
|
|
3
|
+
Different models accept different parameters: a reasoning model rejects
|
|
4
|
+
``temperature``, an o-series model wants ``max_completion_tokens`` instead of
|
|
5
|
+
``max_tokens``, and only some models support a ``reasoning`` effort budget.
|
|
6
|
+
OpenRouter's ``/api/v1/models`` response carries a ``supported_parameters``
|
|
7
|
+
array, ``context_length``, and ``top_provider.max_completion_tokens`` per model
|
|
8
|
+
— this reads them so a request only ever sends parameters a model accepts.
|
|
9
|
+
|
|
10
|
+
Fetched once per process and cached in memory; failures degrade to an empty
|
|
11
|
+
catalog (callers then fall back to their existing default parameters).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, Dict, FrozenSet, Optional
|
|
16
|
+
|
|
17
|
+
from misterdev.core.economics.free_models import _http_fetch
|
|
18
|
+
from misterdev.logging_setup import setup_logger
|
|
19
|
+
|
|
20
|
+
logger = setup_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ModelProfile:
|
|
25
|
+
id: str
|
|
26
|
+
supported_parameters: FrozenSet[str]
|
|
27
|
+
max_completion_tokens: Optional[int] = None
|
|
28
|
+
context_length: Optional[int] = None
|
|
29
|
+
|
|
30
|
+
def supports(self, param: str) -> bool:
|
|
31
|
+
return param in self.supported_parameters
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def supports_reasoning(self) -> bool:
|
|
35
|
+
return (
|
|
36
|
+
"reasoning" in self.supported_parameters
|
|
37
|
+
or "reasoning_effort" in self.supported_parameters
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse(raw: list) -> Dict[str, ModelProfile]:
|
|
42
|
+
profiles: Dict[str, ModelProfile] = {}
|
|
43
|
+
for m in raw:
|
|
44
|
+
if not isinstance(m, dict):
|
|
45
|
+
continue
|
|
46
|
+
model_id = m.get("id")
|
|
47
|
+
if not model_id:
|
|
48
|
+
continue
|
|
49
|
+
params = m.get("supported_parameters") or []
|
|
50
|
+
top = m.get("top_provider")
|
|
51
|
+
max_out = top.get("max_completion_tokens") if isinstance(top, dict) else None
|
|
52
|
+
profiles[model_id] = ModelProfile(
|
|
53
|
+
id=model_id,
|
|
54
|
+
supported_parameters=frozenset(p for p in params if isinstance(p, str)),
|
|
55
|
+
max_completion_tokens=max_out,
|
|
56
|
+
context_length=m.get("context_length"),
|
|
57
|
+
)
|
|
58
|
+
return profiles
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ModelCatalog:
|
|
62
|
+
"""In-memory, lazily-fetched map of model id -> ModelProfile."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, fetcher: Optional[Callable[[], list]] = None):
|
|
65
|
+
self._fetcher = fetcher or _http_fetch
|
|
66
|
+
self._profiles: Optional[Dict[str, ModelProfile]] = None
|
|
67
|
+
|
|
68
|
+
def _ensure(self) -> Dict[str, ModelProfile]:
|
|
69
|
+
if self._profiles is None:
|
|
70
|
+
try:
|
|
71
|
+
self._profiles = _parse(self._fetcher())
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.warning(f"Model catalog fetch failed; no profiles: {e}")
|
|
74
|
+
self._profiles = {}
|
|
75
|
+
return self._profiles
|
|
76
|
+
|
|
77
|
+
def profile(self, model_id: str) -> Optional[ModelProfile]:
|
|
78
|
+
"""Profile for a model, or None when unknown (caller uses its defaults)."""
|
|
79
|
+
return self._ensure().get(model_id)
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Persistent model-performance ledger.
|
|
2
|
+
|
|
3
|
+
Records per-(model, category, complexity) outcomes across runs so model
|
|
4
|
+
selection can be data-driven instead of static or random. Stored as JSON under
|
|
5
|
+
``.orchestrator/model_stats.json`` alongside the other run artifacts.
|
|
6
|
+
|
|
7
|
+
The ledger only stores and scores; the selection policy lives in
|
|
8
|
+
``core.model_selector``. Quality is scored by the Wilson lower confidence bound
|
|
9
|
+
on the gate-pass rate (a conservative estimate that does not let a 1/1 model
|
|
10
|
+
outrank a 95/100 one), and exploration of under-sampled models is handled by an
|
|
11
|
+
optimistic UCB term so new or freshly-rotated models still get tried.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import asdict, dataclass, fields, replace
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from misterdev.logging_setup import setup_logger
|
|
23
|
+
|
|
24
|
+
logger = setup_logger(__name__)
|
|
25
|
+
|
|
26
|
+
# z for a ~95% confidence interval, used by both the Wilson lower bound
|
|
27
|
+
# (conservative quality) and the UCB exploration bonus (optimistic).
|
|
28
|
+
_Z = 1.96
|
|
29
|
+
|
|
30
|
+
# Half-life for time-decaying accumulated outcomes (seconds). Models drift —
|
|
31
|
+
# yesterday's strong model can regress after a provider update — so older
|
|
32
|
+
# observations are exponentially down-weighted toward zero, letting recent
|
|
33
|
+
# outcomes dominate quality/selection. 30 days balances stability against
|
|
34
|
+
# responsiveness; <= 0 disables decay (all history weighed equally).
|
|
35
|
+
_DEFAULT_HALF_LIFE_SECONDS = 30 * 86400.0
|
|
36
|
+
|
|
37
|
+
# Dead-band below which no decay is applied. Decay is meant to fade data ACROSS
|
|
38
|
+
# runs (hours/days apart), not within a single build where the same model is
|
|
39
|
+
# scored several times seconds apart. Holding the factor at exactly 1.0 below an
|
|
40
|
+
# hour keeps the within-build effective counts whole integers, so the selector's
|
|
41
|
+
# integer sample thresholds (e.g. "proven after >= 3 first tries") aren't tripped
|
|
42
|
+
# by sub-second drift. At a 30-day half-life an hour's decay is negligible anyway.
|
|
43
|
+
_MIN_DECAY_ELAPSED_SECONDS = 3600.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _decay_factor(elapsed: float, half_life: float) -> float:
|
|
47
|
+
"""Exponential decay multiplier for ``elapsed`` seconds at ``half_life``.
|
|
48
|
+
|
|
49
|
+
1.0 for non-positive elapsed (no time passed, or a clock that went backward),
|
|
50
|
+
for elapsed within the sub-hour dead-band, or when decay is disabled
|
|
51
|
+
(``half_life`` <= 0); else ``0.5 ** (elapsed / half_life)`` in (0, 1).
|
|
52
|
+
"""
|
|
53
|
+
if half_life <= 0 or elapsed < _MIN_DECAY_ELAPSED_SECONDS:
|
|
54
|
+
return 1.0
|
|
55
|
+
return 0.5 ** (elapsed / half_life)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Field separator for the composite stat key. Unit-separator char: never
|
|
59
|
+
# appears in a model id, category, or complexity.
|
|
60
|
+
_SEP = "␟"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ModelStat:
|
|
65
|
+
"""Aggregated outcomes for one (model, category, complexity) cell."""
|
|
66
|
+
|
|
67
|
+
model: str
|
|
68
|
+
category: str = ""
|
|
69
|
+
complexity: str = ""
|
|
70
|
+
# Effective (time-decayed) counts: floats, not raw integers, because older
|
|
71
|
+
# observations are exponentially down-weighted on each new record.
|
|
72
|
+
attempts: float = 0.0
|
|
73
|
+
successes: float = 0.0 # attempts that passed the validation gates
|
|
74
|
+
first_try_attempts: float = 0.0
|
|
75
|
+
first_try_successes: float = 0.0
|
|
76
|
+
aborts: float = 0.0
|
|
77
|
+
total_cost: float = 0.0
|
|
78
|
+
total_latency: float = 0.0
|
|
79
|
+
last_seen: float = 0.0 # epoch seconds of the most recent record
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def success_rate(self) -> float:
|
|
83
|
+
return self.successes / self.attempts if self.attempts else 0.0
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def first_try_rate(self) -> float:
|
|
87
|
+
return (
|
|
88
|
+
self.first_try_successes / self.first_try_attempts
|
|
89
|
+
if self.first_try_attempts
|
|
90
|
+
else 0.0
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def abort_rate(self) -> float:
|
|
95
|
+
return self.aborts / self.attempts if self.attempts else 0.0
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def avg_cost(self) -> float:
|
|
99
|
+
"""Mean dollar cost of a successful attempt (0.0 with no successes)."""
|
|
100
|
+
return self.total_cost / self.successes if self.successes else 0.0
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def avg_latency(self) -> float:
|
|
104
|
+
return self.total_latency / self.attempts if self.attempts else 0.0
|
|
105
|
+
|
|
106
|
+
def quality_score(self) -> float:
|
|
107
|
+
"""Wilson lower bound on the gate-pass rate.
|
|
108
|
+
|
|
109
|
+
A conservative point estimate of quality: under-sampling widens the
|
|
110
|
+
interval and pulls the score down, so a model must earn its ranking
|
|
111
|
+
with volume, not a lucky single success.
|
|
112
|
+
"""
|
|
113
|
+
n = self.attempts
|
|
114
|
+
if n == 0:
|
|
115
|
+
return 0.0
|
|
116
|
+
# Clamp to [0,1]: a torn read (or float drift from decay scaling) could
|
|
117
|
+
# make successes/attempts exceed 1, and p*(1-p) < 0 would raise ValueError
|
|
118
|
+
# from math.sqrt below.
|
|
119
|
+
p = min(1.0, max(0.0, self.successes / n))
|
|
120
|
+
z2 = _Z * _Z
|
|
121
|
+
denom = 1 + z2 / n
|
|
122
|
+
center = p + z2 / (2 * n)
|
|
123
|
+
margin = _Z * math.sqrt((p * (1 - p) + z2 / (4 * n)) / n)
|
|
124
|
+
return max(0.0, (center - margin) / denom)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ModelLedger:
|
|
128
|
+
"""Thread-safe, file-backed store of per-model outcome statistics."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self, path: Path, half_life_seconds: float = _DEFAULT_HALF_LIFE_SECONDS
|
|
132
|
+
):
|
|
133
|
+
self.path = Path(path)
|
|
134
|
+
self.half_life_seconds = half_life_seconds
|
|
135
|
+
self._lock = threading.Lock()
|
|
136
|
+
self._stats: Dict[str, ModelStat] = {}
|
|
137
|
+
self.load()
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _key(model: str, category: str, complexity: str) -> str:
|
|
141
|
+
return f"{model}{_SEP}{category}{_SEP}{complexity}"
|
|
142
|
+
|
|
143
|
+
def load(self) -> None:
|
|
144
|
+
"""Load stats from disk, tolerating a missing or corrupt file."""
|
|
145
|
+
if not self.path.exists():
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
149
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
150
|
+
logger.warning(
|
|
151
|
+
f"Model ledger at {self.path} unreadable, starting fresh: {e}"
|
|
152
|
+
)
|
|
153
|
+
return
|
|
154
|
+
valid = {f.name for f in fields(ModelStat)}
|
|
155
|
+
with self._lock:
|
|
156
|
+
self._stats = {}
|
|
157
|
+
for key, data in (raw or {}).items():
|
|
158
|
+
if not isinstance(data, dict):
|
|
159
|
+
continue
|
|
160
|
+
# Drop unknown keys so an older/newer schema still loads.
|
|
161
|
+
clean = {k: v for k, v in data.items() if k in valid}
|
|
162
|
+
try:
|
|
163
|
+
self._stats[key] = ModelStat(**clean)
|
|
164
|
+
except TypeError:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
def save(self) -> None:
|
|
168
|
+
"""Persist stats atomically (write-temp-then-rename).
|
|
169
|
+
|
|
170
|
+
Best-effort: the ledger is telemetry that steers model selection, not
|
|
171
|
+
build state, so a write failure (full/read-only disk, missing artifact
|
|
172
|
+
dir) is logged and swallowed rather than allowed to crash an otherwise
|
|
173
|
+
healthy build. The in-memory stats stay authoritative for the rest of
|
|
174
|
+
the run; only cross-run persistence is lost.
|
|
175
|
+
"""
|
|
176
|
+
with self._lock:
|
|
177
|
+
snapshot = {k: asdict(v) for k, v in self._stats.items()}
|
|
178
|
+
from misterdev.utils.file_utils import atomic_write_json
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
atomic_write_json(self.path, snapshot, indent=2, sort_keys=True)
|
|
182
|
+
except OSError as e:
|
|
183
|
+
logger.warning(f"Model ledger at {self.path} could not be saved: {e}")
|
|
184
|
+
|
|
185
|
+
def stat(self, model: str, category: str = "", complexity: str = "") -> ModelStat:
|
|
186
|
+
"""Return a snapshot of the stat cell for a key (empty if absent).
|
|
187
|
+
|
|
188
|
+
Returns a COPY taken under the lock, not the live object: the selector
|
|
189
|
+
reads several fields of the result without the lock, so handing out the
|
|
190
|
+
live cell would let it observe a half-applied ``record()``/``_decay_stat``
|
|
191
|
+
update (e.g. attempts scaled before successes -> a torn read).
|
|
192
|
+
"""
|
|
193
|
+
key = self._key(model, category, complexity)
|
|
194
|
+
with self._lock:
|
|
195
|
+
if key not in self._stats:
|
|
196
|
+
self._stats[key] = ModelStat(
|
|
197
|
+
model=model, category=category, complexity=complexity
|
|
198
|
+
)
|
|
199
|
+
return replace(self._stats[key])
|
|
200
|
+
|
|
201
|
+
def record(
|
|
202
|
+
self,
|
|
203
|
+
model: str,
|
|
204
|
+
category: str = "",
|
|
205
|
+
complexity: str = "",
|
|
206
|
+
*,
|
|
207
|
+
success: bool,
|
|
208
|
+
first_try: bool = False,
|
|
209
|
+
aborted: bool = False,
|
|
210
|
+
cost: float = 0.0,
|
|
211
|
+
latency: float = 0.0,
|
|
212
|
+
timestamp: Optional[float] = None,
|
|
213
|
+
) -> ModelStat:
|
|
214
|
+
"""Record one attempt's outcome and persist the ledger.
|
|
215
|
+
|
|
216
|
+
``success`` means the attempt passed the validation gates. ``first_try``
|
|
217
|
+
marks attempt 0 so first-try success can be tracked separately (it is the
|
|
218
|
+
signal that decides whether a cheap model is worth trying first).
|
|
219
|
+
"""
|
|
220
|
+
ts = time.time() if timestamp is None else timestamp
|
|
221
|
+
key = self._key(model, category, complexity)
|
|
222
|
+
with self._lock:
|
|
223
|
+
s = self._stats.get(key)
|
|
224
|
+
if s is None:
|
|
225
|
+
s = ModelStat(model=model, category=category, complexity=complexity)
|
|
226
|
+
self._stats[key] = s
|
|
227
|
+
else:
|
|
228
|
+
# Down-weight everything accumulated before this record so older
|
|
229
|
+
# outcomes fade. Same-instant records (elapsed 0) don't decay, so
|
|
230
|
+
# a tight burst of attempts behaves exactly like raw counting.
|
|
231
|
+
self._decay_stat(s, ts)
|
|
232
|
+
s.attempts += 1
|
|
233
|
+
if success:
|
|
234
|
+
s.successes += 1
|
|
235
|
+
s.total_cost += cost
|
|
236
|
+
if first_try:
|
|
237
|
+
s.first_try_attempts += 1
|
|
238
|
+
if success:
|
|
239
|
+
s.first_try_successes += 1
|
|
240
|
+
if aborted:
|
|
241
|
+
s.aborts += 1
|
|
242
|
+
s.total_latency += latency
|
|
243
|
+
s.last_seen = ts
|
|
244
|
+
self.save()
|
|
245
|
+
return s
|
|
246
|
+
|
|
247
|
+
def _decay_stat(self, s: ModelStat, now: float) -> None:
|
|
248
|
+
"""Scale a cell's accumulated counts by the time-decay since last_seen.
|
|
249
|
+
|
|
250
|
+
Decays the effective counts and the cost/latency sums together, so every
|
|
251
|
+
rate (success_rate, avg_cost, ...) is preserved while the *confidence*
|
|
252
|
+
(effective sample size) shrinks for a model not seen recently — which
|
|
253
|
+
correctly widens its Wilson interval and lowers its quality score.
|
|
254
|
+
"""
|
|
255
|
+
if s.last_seen <= 0:
|
|
256
|
+
return
|
|
257
|
+
factor = _decay_factor(now - s.last_seen, self.half_life_seconds)
|
|
258
|
+
if factor >= 1.0:
|
|
259
|
+
return
|
|
260
|
+
s.attempts *= factor
|
|
261
|
+
s.successes *= factor
|
|
262
|
+
s.first_try_attempts *= factor
|
|
263
|
+
s.first_try_successes *= factor
|
|
264
|
+
s.aborts *= factor
|
|
265
|
+
s.total_cost *= factor
|
|
266
|
+
s.total_latency *= factor
|
|
267
|
+
|
|
268
|
+
def selection_score(
|
|
269
|
+
self, model: str, category: str, complexity: str, total_observations: int
|
|
270
|
+
) -> float:
|
|
271
|
+
"""Optimistic quality estimate for selection (UCB).
|
|
272
|
+
|
|
273
|
+
Wilson-lower-bound quality plus an exploration bonus that shrinks as a
|
|
274
|
+
model accrues attempts. Under-sampled or freshly-rotated models score
|
|
275
|
+
high enough to be tried without being chosen blindly forever. Returns
|
|
276
|
+
+inf for a never-seen model (textbook UCB) so it is always explored at
|
|
277
|
+
least once before the policy can settle on a proven model.
|
|
278
|
+
"""
|
|
279
|
+
s = self.stat(model, category, complexity)
|
|
280
|
+
if s.attempts == 0:
|
|
281
|
+
return float("inf")
|
|
282
|
+
bonus = _Z * math.sqrt(math.log(max(total_observations, 2)) / s.attempts)
|
|
283
|
+
return s.quality_score() + bonus
|
|
284
|
+
|
|
285
|
+
def total_observations(self, category: str = "", complexity: str = "") -> int:
|
|
286
|
+
"""Total recorded attempts, optionally scoped to a cell's context."""
|
|
287
|
+
with self._lock:
|
|
288
|
+
return sum(
|
|
289
|
+
s.attempts
|
|
290
|
+
for s in self._stats.values()
|
|
291
|
+
if (not category or s.category == category)
|
|
292
|
+
and (not complexity or s.complexity == complexity)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def global_first_try(
|
|
296
|
+
self,
|
|
297
|
+
model: str,
|
|
298
|
+
exclude_category: str = "",
|
|
299
|
+
exclude_complexity: str = "",
|
|
300
|
+
) -> Tuple[float, float]:
|
|
301
|
+
"""A model's first-try (attempts, success_rate) aggregated across its
|
|
302
|
+
(category, complexity) cells, optionally EXCLUDING one cell.
|
|
303
|
+
|
|
304
|
+
Used as an empirical-Bayes prior to warm-start a cell the model has little
|
|
305
|
+
data on: performance elsewhere is evidence — imperfect, since competence
|
|
306
|
+
varies by task kind, so callers blend it with a small weight that cell
|
|
307
|
+
data quickly overrides. The target cell is excluded so its own data isn't
|
|
308
|
+
double-counted into its prior. Returns (0.0, 0.0) for an unseen model.
|
|
309
|
+
"""
|
|
310
|
+
att = succ = 0.0
|
|
311
|
+
with self._lock:
|
|
312
|
+
for s in self._stats.values():
|
|
313
|
+
if s.model != model:
|
|
314
|
+
continue
|
|
315
|
+
if (
|
|
316
|
+
s.category == exclude_category
|
|
317
|
+
and s.complexity == exclude_complexity
|
|
318
|
+
):
|
|
319
|
+
continue
|
|
320
|
+
att += s.first_try_attempts
|
|
321
|
+
succ += s.first_try_successes
|
|
322
|
+
return att, (succ / att if att > 0 else 0.0)
|
|
323
|
+
|
|
324
|
+
def known_models(self) -> List[str]:
|
|
325
|
+
with self._lock:
|
|
326
|
+
return sorted({s.model for s in self._stats.values()})
|
|
327
|
+
|
|
328
|
+
def all_stats(self) -> List[ModelStat]:
|
|
329
|
+
"""A snapshot of every stat cell (for reporting/inspection)."""
|
|
330
|
+
with self._lock:
|
|
331
|
+
return list(self._stats.values())
|