syncmill 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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Difficulty-based provider model routing (PR 1: explicit tiers + observe).
|
|
2
|
+
|
|
3
|
+
Implements the contract in ``docs/difficulty-model-routing-plan.md``: a
|
|
4
|
+
deterministic prompt classifier (``prompt-complexity-v1``), the canonical
|
|
5
|
+
immutable :class:`ModelRoutingDecision`, and the pure helpers the Supervisor
|
|
6
|
+
and CLI consume. This module is a leaf — it imports nothing from ``agents``,
|
|
7
|
+
``supervisor``, or ``cli`` — so the decision can flow into the agents' ambient
|
|
8
|
+
ContextVar scope without an import cycle.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import unicodedata
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from syncmill.config import ModelRoutingConfig, valid_model_selector
|
|
21
|
+
|
|
22
|
+
CLASSIFIER_V1 = "prompt-complexity-v1"
|
|
23
|
+
|
|
24
|
+
TIERS = ("low", "medium", "high")
|
|
25
|
+
|
|
26
|
+
# Reason codes (§4.4 / §4.5).
|
|
27
|
+
REASON_HIGH_RISK = "high_risk"
|
|
28
|
+
REASON_BROAD_SCOPE = "broad_scope"
|
|
29
|
+
REASON_MULTI_TARGET = "multi_target"
|
|
30
|
+
REASON_LONG_STRUCTURED = "long_structured_prompt"
|
|
31
|
+
REASON_NARROW_SIMPLE = "narrow_simple_intent"
|
|
32
|
+
REASON_DEFAULT_MEDIUM = "default_medium"
|
|
33
|
+
REASON_EXPLICIT = "explicit_override"
|
|
34
|
+
REASON_AFFINITY = "selector_affinity"
|
|
35
|
+
|
|
36
|
+
_KNOWN_REASONS = {
|
|
37
|
+
REASON_HIGH_RISK,
|
|
38
|
+
REASON_BROAD_SCOPE,
|
|
39
|
+
REASON_MULTI_TARGET,
|
|
40
|
+
REASON_LONG_STRUCTURED,
|
|
41
|
+
REASON_NARROW_SIMPLE,
|
|
42
|
+
REASON_DEFAULT_MEDIUM,
|
|
43
|
+
REASON_EXPLICIT,
|
|
44
|
+
REASON_AFFINITY,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# The tier each classifier reason implies (§4.4) — a stored auto decision whose
|
|
48
|
+
# resolved_tier contradicts its reason code was not produced by the classifier.
|
|
49
|
+
_REASON_TIER = {
|
|
50
|
+
REASON_HIGH_RISK: "high",
|
|
51
|
+
REASON_BROAD_SCOPE: "high",
|
|
52
|
+
REASON_MULTI_TARGET: "high",
|
|
53
|
+
REASON_LONG_STRUCTURED: "high",
|
|
54
|
+
REASON_NARROW_SIMPLE: "low",
|
|
55
|
+
REASON_DEFAULT_MEDIUM: "medium",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
CORRUPT_ROUTING_ERROR = "corrupt model routing metadata"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ClassifierError(Exception):
|
|
62
|
+
"""The deterministic classifier failed; observe callers degrade + warn."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AllUnmappedError(Exception):
|
|
66
|
+
"""An applied tier has no selector for any eligible agent (§3.3)."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, tier: str) -> None:
|
|
69
|
+
super().__init__(
|
|
70
|
+
f"Model routing selected {tier}, but no eligible agent has a mapping"
|
|
71
|
+
)
|
|
72
|
+
self.tier = tier
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# prompt-complexity-v1 (§4)
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
# English markers use letter/digit-boundary phrase matching with hyphen and
|
|
80
|
+
# underscore normalized to spaces; Korean markers use plain substring matching
|
|
81
|
+
# because agglutinated particles/suffixes attach directly to the marker
|
|
82
|
+
# (마이그레이션을, 배포해줘) and a boundary rule would never fire (§4.3).
|
|
83
|
+
_HIGH_RISK_EN = (
|
|
84
|
+
"migration", "schema change", "authentication", "authorization",
|
|
85
|
+
"security", "permission", "concurrency", "race condition", "data loss",
|
|
86
|
+
"backward compatibility", "public api", "deploy", "deployment",
|
|
87
|
+
"release", "production",
|
|
88
|
+
)
|
|
89
|
+
_HIGH_RISK_KO = (
|
|
90
|
+
"마이그레이션", "스키마 변경", "인증", "인가", "보안", "권한", "동시성",
|
|
91
|
+
"경쟁 상태", "데이터 손실", "하위 호환", "공개 api", "배포", "릴리스", "운영",
|
|
92
|
+
)
|
|
93
|
+
_BROAD_SCOPE_EN = (
|
|
94
|
+
"entire repo", "whole repository", "across the repository", "all modules",
|
|
95
|
+
"end to end", "architecture", "redesign",
|
|
96
|
+
)
|
|
97
|
+
_BROAD_SCOPE_KO = (
|
|
98
|
+
"전체 저장소", "저장소 전체", "모든 모듈", "전반", "엔드투엔드",
|
|
99
|
+
"아키텍처", "재설계",
|
|
100
|
+
)
|
|
101
|
+
_SIMPLE_EN = (
|
|
102
|
+
"status", "show", "list", "summarize", "translate", "rename", "typo",
|
|
103
|
+
"format", "lint",
|
|
104
|
+
)
|
|
105
|
+
_SIMPLE_KO = ("상태", "보여", "목록", "요약", "번역", "이름 변경", "오타", "포맷", "린트")
|
|
106
|
+
|
|
107
|
+
_LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+")
|
|
108
|
+
_PATH_TOKEN_RE = re.compile(r"(?<!\w)(?:\.{0,2}/)?[\w.@+-]+(?:/[\w.@+-]+)+")
|
|
109
|
+
_CODE_SPAN_RE = re.compile(r"`([^`\n]+)`")
|
|
110
|
+
# NOTE: no dedicated fence handling — the §4.2 fence semantics ("a fenced block
|
|
111
|
+
# is not itself one target; path tokens inside count normally") fall out of the
|
|
112
|
+
# path/span regexes as written (a multi-line fence can never be a single-line
|
|
113
|
+
# code span, and _PATH_TOKEN_RE scans inside it like anywhere else).
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _normalize(prompt: str) -> str:
|
|
117
|
+
text = unicodedata.normalize("NFKC", prompt)
|
|
118
|
+
text = text.casefold()
|
|
119
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
120
|
+
return text.strip()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _phrase_boundary_match(text: str, marker: str) -> bool:
|
|
124
|
+
"""English phrase match at Unicode letter/digit boundaries.
|
|
125
|
+
|
|
126
|
+
Hyphen/underscore in the text are treated like spaces so ``schema-change``
|
|
127
|
+
matches the marker ``schema change``.
|
|
128
|
+
"""
|
|
129
|
+
normalized = re.sub(r"[-_]", " ", text)
|
|
130
|
+
pattern = r"(?<![^\W_])" + re.escape(marker) + r"(?![^\W_])"
|
|
131
|
+
# \w includes letters/digits/underscore; underscores were already folded to
|
|
132
|
+
# spaces above, so the lookarounds reduce to letter/digit boundaries.
|
|
133
|
+
return re.search(pattern, normalized) is not None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _any_marker(text: str, en: tuple[str, ...], ko: tuple[str, ...]) -> bool:
|
|
137
|
+
return any(_phrase_boundary_match(text, m) for m in en) or any(
|
|
138
|
+
m in text for m in ko
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _count_targets(text: str) -> tuple[int, int]:
|
|
143
|
+
"""Return ``(path_targets, targets)`` per §4.2.
|
|
144
|
+
|
|
145
|
+
``path_targets`` excludes URL tokens (anything participating in a
|
|
146
|
+
``scheme://`` sequence); ``targets`` unions in single-line inline code
|
|
147
|
+
spans. Fenced code blocks are not themselves targets, but path-like tokens
|
|
148
|
+
inside them count normally.
|
|
149
|
+
"""
|
|
150
|
+
# URL exclusion: drop any whitespace-delimited token containing "://"
|
|
151
|
+
# BEFORE scanning, so no sub-span of a URL (host/path tail) can count.
|
|
152
|
+
scannable = re.sub(r"\S*://\S*", " ", text)
|
|
153
|
+
paths = {m.group(0) for m in _PATH_TOKEN_RE.finditer(scannable)}
|
|
154
|
+
# Spans are extracted from the ORIGINAL text, so a URL inside an inline
|
|
155
|
+
# code span (`https://…`) still counts as a target. Deliberate: it only
|
|
156
|
+
# affects the low rule's `targets <= 1`, and the error direction is the
|
|
157
|
+
# conservative one (medium instead of low) — URL exclusion exists to keep
|
|
158
|
+
# doc links out of the EXPENSIVE multi_target rule, which reads only
|
|
159
|
+
# `paths`.
|
|
160
|
+
spans = {
|
|
161
|
+
m.group(1).strip()
|
|
162
|
+
for m in _CODE_SPAN_RE.finditer(text)
|
|
163
|
+
if m.group(1).strip()
|
|
164
|
+
}
|
|
165
|
+
return len(paths), len(paths | spans)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def classify_prompt(prompt: str) -> tuple[str, str]:
|
|
169
|
+
"""Classify a raw task prompt into ``(tier, reason_code)`` (§4.4).
|
|
170
|
+
|
|
171
|
+
Deterministic and self-contained: no filesystem, network, or model calls.
|
|
172
|
+
Any internal failure is wrapped in :class:`ClassifierError` so callers can
|
|
173
|
+
apply the §9 degrade policy without catching arbitrary exceptions.
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
text = _normalize(prompt)
|
|
177
|
+
char_count = len(text)
|
|
178
|
+
lines = text.split("\n")
|
|
179
|
+
nonempty_lines = sum(1 for line in lines if line.strip())
|
|
180
|
+
list_items = sum(1 for line in lines if _LIST_ITEM_RE.match(line))
|
|
181
|
+
path_targets, targets = _count_targets(text)
|
|
182
|
+
|
|
183
|
+
if _any_marker(text, _HIGH_RISK_EN, _HIGH_RISK_KO):
|
|
184
|
+
return "high", REASON_HIGH_RISK
|
|
185
|
+
if _any_marker(text, _BROAD_SCOPE_EN, _BROAD_SCOPE_KO):
|
|
186
|
+
return "high", REASON_BROAD_SCOPE
|
|
187
|
+
if path_targets >= 3:
|
|
188
|
+
return "high", REASON_MULTI_TARGET
|
|
189
|
+
if char_count >= 1200 and list_items >= 4:
|
|
190
|
+
return "high", REASON_LONG_STRUCTURED
|
|
191
|
+
if (
|
|
192
|
+
char_count <= 240
|
|
193
|
+
and nonempty_lines <= 3
|
|
194
|
+
and targets <= 1
|
|
195
|
+
and list_items <= 1
|
|
196
|
+
and _any_marker(text, _SIMPLE_EN, _SIMPLE_KO)
|
|
197
|
+
):
|
|
198
|
+
return "low", REASON_NARROW_SIMPLE
|
|
199
|
+
return "medium", REASON_DEFAULT_MEDIUM
|
|
200
|
+
except Exception as exc: # pragma: no cover - defensive wrap
|
|
201
|
+
raise ClassifierError(str(exc)) from exc
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Canonical decision (§5.1)
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _frozen(mapping: Mapping[str, str | None]) -> Mapping[str, str | None]:
|
|
210
|
+
return MappingProxyType(dict(mapping))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(frozen=True)
|
|
214
|
+
class ModelRoutingDecision:
|
|
215
|
+
"""One immutable routing decision per top-level run.
|
|
216
|
+
|
|
217
|
+
``recommended_selectors``/``effective_selectors`` are projected onto the
|
|
218
|
+
eligible lineup only (never the full config tier table) and are stored as
|
|
219
|
+
read-only mapping proxies — the frozen dataclass alone would not freeze a
|
|
220
|
+
contained dict.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
schema_version: int
|
|
224
|
+
requested: str
|
|
225
|
+
resolved_tier: str
|
|
226
|
+
source: str
|
|
227
|
+
classifier_version: str | None
|
|
228
|
+
config_mode: str
|
|
229
|
+
applied: bool
|
|
230
|
+
reason_codes: tuple[str, ...]
|
|
231
|
+
recommended_selectors: Mapping[str, str | None]
|
|
232
|
+
effective_selectors: Mapping[str, str | None]
|
|
233
|
+
|
|
234
|
+
def __post_init__(self) -> None:
|
|
235
|
+
object.__setattr__(
|
|
236
|
+
self, "recommended_selectors", _frozen(self.recommended_selectors)
|
|
237
|
+
)
|
|
238
|
+
object.__setattr__(
|
|
239
|
+
self, "effective_selectors", _frozen(self.effective_selectors)
|
|
240
|
+
)
|
|
241
|
+
object.__setattr__(self, "reason_codes", tuple(self.reason_codes))
|
|
242
|
+
|
|
243
|
+
def as_dict(self) -> dict[str, Any]:
|
|
244
|
+
"""The ONLY serializer — deterministic sorted mappings and reasons."""
|
|
245
|
+
return {
|
|
246
|
+
"schema_version": self.schema_version,
|
|
247
|
+
"requested": self.requested,
|
|
248
|
+
"resolved_tier": self.resolved_tier,
|
|
249
|
+
"source": self.source,
|
|
250
|
+
"classifier_version": self.classifier_version,
|
|
251
|
+
"config_mode": self.config_mode,
|
|
252
|
+
"applied": self.applied,
|
|
253
|
+
"reason_codes": sorted(self.reason_codes),
|
|
254
|
+
"recommended_selectors": dict(sorted(self.recommended_selectors.items())),
|
|
255
|
+
"effective_selectors": dict(sorted(self.effective_selectors.items())),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _project(
|
|
260
|
+
cfg: ModelRoutingConfig, tier: str, lineup: Sequence[str]
|
|
261
|
+
) -> dict[str, str | None]:
|
|
262
|
+
table = cfg.models.get(tier, {})
|
|
263
|
+
return {agent_id: table.get(agent_id) for agent_id in lineup}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def build_decision(
|
|
267
|
+
cfg: ModelRoutingConfig,
|
|
268
|
+
*,
|
|
269
|
+
prompt: str,
|
|
270
|
+
lineup: Sequence[str],
|
|
271
|
+
requested: str | None,
|
|
272
|
+
) -> ModelRoutingDecision | None:
|
|
273
|
+
"""Compute the run's single decision (§5.2), or ``None`` when routing is off.
|
|
274
|
+
|
|
275
|
+
Raises :class:`ClassifierError` (auto path) for the caller to degrade+warn,
|
|
276
|
+
and :class:`AllUnmappedError` (explicit path) when the applied tier has no
|
|
277
|
+
selector for any eligible agent.
|
|
278
|
+
"""
|
|
279
|
+
if requested in (None, ""):
|
|
280
|
+
if cfg.mode == "off":
|
|
281
|
+
return None
|
|
282
|
+
requested = "auto"
|
|
283
|
+
|
|
284
|
+
if requested == "auto":
|
|
285
|
+
tier, reason = classify_prompt(prompt)
|
|
286
|
+
recommended = _project(cfg, tier, lineup)
|
|
287
|
+
return ModelRoutingDecision(
|
|
288
|
+
schema_version=1,
|
|
289
|
+
requested="auto",
|
|
290
|
+
resolved_tier=tier,
|
|
291
|
+
source="auto",
|
|
292
|
+
classifier_version=CLASSIFIER_V1,
|
|
293
|
+
config_mode=cfg.mode,
|
|
294
|
+
applied=False,
|
|
295
|
+
reason_codes=(reason,),
|
|
296
|
+
recommended_selectors=recommended,
|
|
297
|
+
effective_selectors={agent_id: None for agent_id in lineup},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Explicit tier: user-requested application, honored even under mode=off.
|
|
301
|
+
# An empty lineup is by definition all-unmapped — never a silently
|
|
302
|
+
# applied=true decision over empty maps.
|
|
303
|
+
recommended = _project(cfg, requested, lineup)
|
|
304
|
+
if not lineup or all(sel is None for sel in recommended.values()):
|
|
305
|
+
raise AllUnmappedError(requested)
|
|
306
|
+
return ModelRoutingDecision(
|
|
307
|
+
schema_version=1,
|
|
308
|
+
requested=requested,
|
|
309
|
+
resolved_tier=requested,
|
|
310
|
+
source="explicit",
|
|
311
|
+
classifier_version=None,
|
|
312
|
+
config_mode=cfg.mode,
|
|
313
|
+
applied=True,
|
|
314
|
+
reason_codes=(REASON_EXPLICIT,),
|
|
315
|
+
recommended_selectors=recommended,
|
|
316
|
+
effective_selectors=dict(recommended),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def inherit_decision(
|
|
321
|
+
stored: Any, *, agent_id: str
|
|
322
|
+
) -> ModelRoutingDecision | None:
|
|
323
|
+
"""Rebuild an inherited decision from a parent manifest block (§6.2).
|
|
324
|
+
|
|
325
|
+
``None``/absent → legacy parent → ``None`` (resume with no model flag).
|
|
326
|
+
Anything present must be a strictly valid v1 block — EVERY canonical field
|
|
327
|
+
well-typed (both selector maps included), the cross-field invariants the
|
|
328
|
+
builder guarantees intact (an unapplied decision has no non-null effective
|
|
329
|
+
selector; an applied one has at least one), and an ``effective_selectors``
|
|
330
|
+
entry present for the winning ``agent_id``. Any violation raises
|
|
331
|
+
``ValueError(CORRUPT_ROUTING_ERROR)`` (fail closed, §9).
|
|
332
|
+
"""
|
|
333
|
+
if stored is None:
|
|
334
|
+
return None
|
|
335
|
+
if not isinstance(stored, dict):
|
|
336
|
+
raise ValueError(CORRUPT_ROUTING_ERROR)
|
|
337
|
+
schema_version = stored.get("schema_version")
|
|
338
|
+
if isinstance(schema_version, bool) or schema_version != 1:
|
|
339
|
+
raise ValueError(CORRUPT_ROUTING_ERROR)
|
|
340
|
+
requested = stored.get("requested")
|
|
341
|
+
resolved = stored.get("resolved_tier")
|
|
342
|
+
source = stored.get("source")
|
|
343
|
+
mode = stored.get("config_mode")
|
|
344
|
+
classifier_version = stored.get("classifier_version")
|
|
345
|
+
applied = stored.get("applied")
|
|
346
|
+
reasons = stored.get("reason_codes")
|
|
347
|
+
recommended = stored.get("recommended_selectors")
|
|
348
|
+
effective = stored.get("effective_selectors")
|
|
349
|
+
|
|
350
|
+
def _valid_map(mapping: Any) -> bool:
|
|
351
|
+
return isinstance(mapping, dict) and all(
|
|
352
|
+
isinstance(key, str) and (value is None or valid_model_selector(value))
|
|
353
|
+
for key, value in mapping.items()
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
if (
|
|
357
|
+
requested not in ("auto", *TIERS)
|
|
358
|
+
or resolved not in TIERS
|
|
359
|
+
or source not in ("auto", "explicit", "inherited")
|
|
360
|
+
or mode not in ("off", "observe", "enforce")
|
|
361
|
+
or not (classifier_version is None or isinstance(classifier_version, str))
|
|
362
|
+
or not isinstance(applied, bool)
|
|
363
|
+
or not isinstance(reasons, list)
|
|
364
|
+
or not all(isinstance(r, str) and r in _KNOWN_REASONS for r in reasons)
|
|
365
|
+
or not _valid_map(recommended)
|
|
366
|
+
or not _valid_map(effective)
|
|
367
|
+
or agent_id not in effective
|
|
368
|
+
):
|
|
369
|
+
raise ValueError(CORRUPT_ROUTING_ERROR)
|
|
370
|
+
# Cross-field invariants the canonical builder guarantees — a block that
|
|
371
|
+
# contradicts any of them was not produced by syncmill and fails closed
|
|
372
|
+
# rather than being reinterpreted from whichever field one happens to read:
|
|
373
|
+
# * both maps cover the SAME lineup;
|
|
374
|
+
# * an applied decision's effective map IS its recommended map (and has
|
|
375
|
+
# at least one selector), an unapplied one is all-null;
|
|
376
|
+
# * the source shape is internally consistent (auto = classifier ran and
|
|
377
|
+
# nothing applied in this release; explicit = the requested tier, no
|
|
378
|
+
# classifier, exactly the explicit_override reason; inherited carries
|
|
379
|
+
# the selector_affinity marker).
|
|
380
|
+
classifier_reasons = _KNOWN_REASONS - {REASON_EXPLICIT, REASON_AFFINITY}
|
|
381
|
+
consistent = set(recommended) == set(effective) and applied == any(
|
|
382
|
+
value is not None for value in effective.values()
|
|
383
|
+
)
|
|
384
|
+
if applied:
|
|
385
|
+
consistent = consistent and effective == recommended
|
|
386
|
+
if source == "auto":
|
|
387
|
+
consistent = (
|
|
388
|
+
consistent
|
|
389
|
+
and requested == "auto"
|
|
390
|
+
and classifier_version == CLASSIFIER_V1
|
|
391
|
+
and not applied
|
|
392
|
+
and len(reasons) == 1
|
|
393
|
+
and reasons[0] in classifier_reasons
|
|
394
|
+
and resolved == _REASON_TIER.get(reasons[0])
|
|
395
|
+
)
|
|
396
|
+
elif source == "explicit":
|
|
397
|
+
consistent = (
|
|
398
|
+
consistent
|
|
399
|
+
and requested == resolved
|
|
400
|
+
and classifier_version is None
|
|
401
|
+
and applied
|
|
402
|
+
and reasons == [REASON_EXPLICIT]
|
|
403
|
+
)
|
|
404
|
+
else:
|
|
405
|
+
# Inherited (a continuation of a continuation): inherit_decision itself
|
|
406
|
+
# produced this block, so its shape is fully determined — single-agent
|
|
407
|
+
# maps keyed to the continued winner, recommended == effective, and
|
|
408
|
+
# reasons == the ancestor's ONE origin reason + selector_affinity, with
|
|
409
|
+
# the origin's own field constraints (explicit ancestry: no classifier,
|
|
410
|
+
# requested == resolved; auto ancestry: classifier v1, requested auto,
|
|
411
|
+
# and the reason must resolve to the tier it implies).
|
|
412
|
+
origin_reasons = [r for r in reasons if r != REASON_AFFINITY]
|
|
413
|
+
consistent = (
|
|
414
|
+
consistent
|
|
415
|
+
and REASON_AFFINITY in reasons
|
|
416
|
+
and len(reasons) == len(set(reasons))
|
|
417
|
+
and len(origin_reasons) == 1
|
|
418
|
+
and set(effective) == {agent_id}
|
|
419
|
+
and recommended == effective
|
|
420
|
+
)
|
|
421
|
+
if consistent:
|
|
422
|
+
origin = origin_reasons[0]
|
|
423
|
+
if origin == REASON_EXPLICIT:
|
|
424
|
+
consistent = classifier_version is None and requested == resolved
|
|
425
|
+
elif origin in classifier_reasons:
|
|
426
|
+
consistent = (
|
|
427
|
+
classifier_version == CLASSIFIER_V1
|
|
428
|
+
and requested == "auto"
|
|
429
|
+
and resolved == _REASON_TIER.get(origin)
|
|
430
|
+
)
|
|
431
|
+
else:
|
|
432
|
+
consistent = False
|
|
433
|
+
if not consistent:
|
|
434
|
+
raise ValueError(CORRUPT_ROUTING_ERROR)
|
|
435
|
+
selector = effective[agent_id]
|
|
436
|
+
reason_codes = tuple(reasons)
|
|
437
|
+
if REASON_AFFINITY not in reason_codes:
|
|
438
|
+
reason_codes = reason_codes + (REASON_AFFINITY,)
|
|
439
|
+
return ModelRoutingDecision(
|
|
440
|
+
schema_version=1,
|
|
441
|
+
requested=requested,
|
|
442
|
+
resolved_tier=resolved,
|
|
443
|
+
source="inherited",
|
|
444
|
+
classifier_version=classifier_version,
|
|
445
|
+
config_mode=mode,
|
|
446
|
+
applied=selector is not None,
|
|
447
|
+
reason_codes=reason_codes,
|
|
448
|
+
recommended_selectors={agent_id: selector},
|
|
449
|
+
effective_selectors={agent_id: selector},
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
# Output (§3.4)
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def cli_default_count(decision: ModelRoutingDecision) -> int:
|
|
459
|
+
"""Agents whose resolved tier has no configured selector.
|
|
460
|
+
|
|
461
|
+
Counted over ``recommended_selectors`` — in observe mode every effective
|
|
462
|
+
value is intentionally ``None``, so effective would always read all-default.
|
|
463
|
+
"""
|
|
464
|
+
return sum(1 for sel in decision.recommended_selectors.values() if sel is None)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def render_banner(decision: ModelRoutingDecision) -> str:
|
|
468
|
+
"""The compact one-line banner (§3.4).
|
|
469
|
+
|
|
470
|
+
An auto decision is ALWAYS labeled ``observe`` — the auto builder never
|
|
471
|
+
applies (``applied`` is hard-coded ``False`` there; only explicit/inherited
|
|
472
|
+
decisions apply), and `--difficulty auto` under ``mode="off"`` IS a one-run
|
|
473
|
+
observe (§3.2): the honest ``config_mode="off"`` snapshot in the serialized
|
|
474
|
+
block must not leak "off" into the label of a run that classified.
|
|
475
|
+
"""
|
|
476
|
+
label = "observe" if decision.source == "auto" else decision.source
|
|
477
|
+
applied = "applied" if decision.applied else "not applied"
|
|
478
|
+
parts = [f"model routing: {decision.resolved_tier} / {label} / {applied}"]
|
|
479
|
+
defaults = cli_default_count(decision)
|
|
480
|
+
if defaults:
|
|
481
|
+
parts.append(f"{defaults} cli-default")
|
|
482
|
+
return " / ".join(parts)
|