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.
Files changed (136) hide show
  1. misterdev/__init__.py +3 -0
  2. misterdev/agent.py +2166 -0
  3. misterdev/agent_helpers.py +194 -0
  4. misterdev/analyzers/__init__.py +0 -0
  5. misterdev/analyzers/project_analyzer/__init__.py +246 -0
  6. misterdev/analyzers/project_analyzer/detection.py +146 -0
  7. misterdev/analyzers/project_analyzer/merge.py +137 -0
  8. misterdev/analyzers/project_analyzer/overview.py +312 -0
  9. misterdev/analyzers/project_analyzer/prompts.py +83 -0
  10. misterdev/cli.py +370 -0
  11. misterdev/config.py +521 -0
  12. misterdev/core/__init__.py +0 -0
  13. misterdev/core/audit.py +88 -0
  14. misterdev/core/config.py +10 -0
  15. misterdev/core/context/__init__.py +0 -0
  16. misterdev/core/context/change_tracker.py +218 -0
  17. misterdev/core/context/contracts/__init__.py +63 -0
  18. misterdev/core/context/contracts/_log.py +9 -0
  19. misterdev/core/context/contracts/_text.py +12 -0
  20. misterdev/core/context/contracts/extraction.py +30 -0
  21. misterdev/core/context/contracts/python_generic.py +42 -0
  22. misterdev/core/context/contracts/registry.py +127 -0
  23. misterdev/core/context/contracts/rust_line.py +225 -0
  24. misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
  25. misterdev/core/context/lsp.py +174 -0
  26. misterdev/core/context/scratchpad.py +111 -0
  27. misterdev/core/context/topography/__init__.py +43 -0
  28. misterdev/core/context/topography/_log.py +10 -0
  29. misterdev/core/context/topography/cache.py +91 -0
  30. misterdev/core/context/topography/engine.py +172 -0
  31. misterdev/core/context/topography/graph.py +675 -0
  32. misterdev/core/context/topography/nodes.py +55 -0
  33. misterdev/core/context/topography/parsers.py +95 -0
  34. misterdev/core/context/topography/syntax.py +54 -0
  35. misterdev/core/economics/__init__.py +0 -0
  36. misterdev/core/economics/context_budget.py +175 -0
  37. misterdev/core/economics/embeddings.py +232 -0
  38. misterdev/core/economics/free_models.py +108 -0
  39. misterdev/core/economics/llm_cache.py +105 -0
  40. misterdev/core/economics/model_catalog.py +79 -0
  41. misterdev/core/economics/model_ledger.py +331 -0
  42. misterdev/core/economics/model_selector.py +281 -0
  43. misterdev/core/execution/__init__.py +0 -0
  44. misterdev/core/execution/bounded.py +50 -0
  45. misterdev/core/execution/container.py +221 -0
  46. misterdev/core/execution/error_classifier.py +366 -0
  47. misterdev/core/execution/error_resolver.py +201 -0
  48. misterdev/core/execution/governance.py +283 -0
  49. misterdev/core/execution/outcomes.py +50 -0
  50. misterdev/core/execution/progress.py +120 -0
  51. misterdev/core/execution/project.py +231 -0
  52. misterdev/core/execution/registry.py +97 -0
  53. misterdev/core/execution/runtime.py +279 -0
  54. misterdev/core/gitcmd.py +39 -0
  55. misterdev/core/integration/__init__.py +0 -0
  56. misterdev/core/integration/mcp.py +368 -0
  57. misterdev/core/integration/mcp_gather.py +186 -0
  58. misterdev/core/models.py +35 -0
  59. misterdev/core/modes.py +184 -0
  60. misterdev/core/planning/__init__.py +0 -0
  61. misterdev/core/planning/advisor.py +89 -0
  62. misterdev/core/planning/assessment.py +135 -0
  63. misterdev/core/planning/decomposer.py +387 -0
  64. misterdev/core/planning/metacognition.py +103 -0
  65. misterdev/core/planning/sovereign.py +308 -0
  66. misterdev/core/planning/targets.py +201 -0
  67. misterdev/core/reporting/__init__.py +0 -0
  68. misterdev/core/reporting/report.py +377 -0
  69. misterdev/core/reporting/report_view.py +151 -0
  70. misterdev/core/task.py +163 -0
  71. misterdev/core/verification/__init__.py +0 -0
  72. misterdev/core/verification/claim_verifier.py +210 -0
  73. misterdev/core/verification/critic.py +324 -0
  74. misterdev/core/verification/gatekeeper/__init__.py +631 -0
  75. misterdev/core/verification/gatekeeper/constants.py +138 -0
  76. misterdev/core/verification/gatekeeper/helpers.py +28 -0
  77. misterdev/core/verification/goal_check.py +219 -0
  78. misterdev/core/verification/independent.py +68 -0
  79. misterdev/core/verification/mutation_gate.py +221 -0
  80. misterdev/core/verification/preflight.py +95 -0
  81. misterdev/core/verification/spec_tests.py +175 -0
  82. misterdev/core/verification/validator.py +495 -0
  83. misterdev/core/verification/vision_verify.py +185 -0
  84. misterdev/core/verification/web_verify.py +408 -0
  85. misterdev/environments/__init__.py +0 -0
  86. misterdev/environments/base_env.py +18 -0
  87. misterdev/environments/container_env.py +87 -0
  88. misterdev/environments/venv_env.py +42 -0
  89. misterdev/llm/__init__.py +0 -0
  90. misterdev/llm/client/__init__.py +152 -0
  91. misterdev/llm/client/base.py +382 -0
  92. misterdev/llm/client/edits.py +70 -0
  93. misterdev/llm/client/embeddings.py +121 -0
  94. misterdev/llm/client/errors.py +134 -0
  95. misterdev/llm/client/providers.py +535 -0
  96. misterdev/llm/client/response.py +24 -0
  97. misterdev/llm/prompt_manager.py +82 -0
  98. misterdev/llm/responses/__init__.py +34 -0
  99. misterdev/llm/responses/apply.py +131 -0
  100. misterdev/llm/responses/json_extract.py +80 -0
  101. misterdev/llm/responses/models.py +43 -0
  102. misterdev/llm/responses/parsing.py +494 -0
  103. misterdev/logging_setup.py +20 -0
  104. misterdev/mcp_server.py +208 -0
  105. misterdev/nl_cli.py +149 -0
  106. misterdev/plugins.py +115 -0
  107. misterdev/py.typed +0 -0
  108. misterdev/task_executors/__init__.py +0 -0
  109. misterdev/task_executors/base_executor.py +10 -0
  110. misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
  111. misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
  112. misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
  113. misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
  114. misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
  115. misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
  116. misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
  117. misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
  118. misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
  119. misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
  120. misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
  121. misterdev/tools/__init__.py +19 -0
  122. misterdev/tools/base_tool.py +14 -0
  123. misterdev/tools/command.py +75 -0
  124. misterdev/tools/file_io.py +69 -0
  125. misterdev/tools/formatter.py +26 -0
  126. misterdev/tools/git_tool.py +90 -0
  127. misterdev/utils/__init__.py +0 -0
  128. misterdev/utils/file_utils.py +169 -0
  129. misterdev/utils/process.py +23 -0
  130. misterdev-0.2.0.dist-info/METADATA +326 -0
  131. misterdev-0.2.0.dist-info/RECORD +136 -0
  132. misterdev-0.2.0.dist-info/WHEEL +5 -0
  133. misterdev-0.2.0.dist-info/entry_points.txt +3 -0
  134. misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
  135. misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
  136. 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())