graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
"""Deterministic eligibility, ranking, and promotion-confidence policy."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from .contracts import (
|
|
12
|
+
CapabilitySnapshot,
|
|
13
|
+
CliIdentity,
|
|
14
|
+
Effort,
|
|
15
|
+
PermissionMode,
|
|
16
|
+
ProviderId,
|
|
17
|
+
RiskTier,
|
|
18
|
+
TaskCategory,
|
|
19
|
+
TaskProfile,
|
|
20
|
+
)
|
|
21
|
+
from .registry import (
|
|
22
|
+
BUNDLED_PROFILES,
|
|
23
|
+
ModelRole,
|
|
24
|
+
RegistrySnapshot,
|
|
25
|
+
UsageClass,
|
|
26
|
+
lifecycle_is_eligible,
|
|
27
|
+
profile_is_eligible,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
POLICY_VERSION: Final = "2"
|
|
31
|
+
EVIDENCE_VERSION: Final = "1"
|
|
32
|
+
_Z_95: Final = 1.959963984540054
|
|
33
|
+
|
|
34
|
+
_ROLE_BONUSES: Final = {
|
|
35
|
+
TaskCategory.DOCUMENTATION: {
|
|
36
|
+
ModelRole.CODING_PRIMARY: 100,
|
|
37
|
+
ModelRole.CODING: 40,
|
|
38
|
+
},
|
|
39
|
+
TaskCategory.ISOLATED_CODE: {
|
|
40
|
+
ModelRole.CODING_PRIMARY: 100,
|
|
41
|
+
ModelRole.CODING: 40,
|
|
42
|
+
},
|
|
43
|
+
TaskCategory.FEATURE: {
|
|
44
|
+
ModelRole.CODING_PRIMARY: 100,
|
|
45
|
+
ModelRole.CODING: 40,
|
|
46
|
+
ModelRole.AGENTIC: 20,
|
|
47
|
+
},
|
|
48
|
+
TaskCategory.REFACTOR: {
|
|
49
|
+
ModelRole.CODING_PRIMARY: 80,
|
|
50
|
+
ModelRole.CODING: 40,
|
|
51
|
+
ModelRole.REASONING: 20,
|
|
52
|
+
},
|
|
53
|
+
TaskCategory.ARCHITECTURE: {
|
|
54
|
+
ModelRole.LONG_CONTEXT: 80,
|
|
55
|
+
ModelRole.REASONING: 40,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
_USAGE_PENALTIES: Final = {
|
|
59
|
+
UsageClass.MEDIUM: 0,
|
|
60
|
+
UsageClass.HIGH: 40,
|
|
61
|
+
}
|
|
62
|
+
_CODE_CAPABILITIES: Final = frozenset({"code"})
|
|
63
|
+
_REFACTOR_CAPABILITIES: Final = frozenset({"code", "reasoning"})
|
|
64
|
+
_ARCHITECTURE_CAPABILITIES: Final = frozenset({"architecture"})
|
|
65
|
+
CLI_MINIMUM_LEARNING_SAMPLES: Final = 20
|
|
66
|
+
CLI_RECENCY_HALF_LIFE_DAYS: Final = 30
|
|
67
|
+
_LEARNED_PROVIDERS: Final = ("claude-code", "codex")
|
|
68
|
+
_LEARNED_RISK_CEILINGS: Final = {"claude-code": "medium", "codex": "medium"}
|
|
69
|
+
_LEARNED_PERMISSION_CEILING: Final = "workspace-write"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class CandidateMetrics:
|
|
74
|
+
model_id: str
|
|
75
|
+
effort: Effort
|
|
76
|
+
repository_success_millis: int | None
|
|
77
|
+
global_success_millis: int
|
|
78
|
+
expected_input_tokens: int
|
|
79
|
+
expected_output_tokens: int
|
|
80
|
+
expected_latency_ms: int
|
|
81
|
+
retry_rate_millis: int
|
|
82
|
+
escalation_rate_millis: int
|
|
83
|
+
quota_scarcity_millis: int
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class _ValidatedCandidateMetrics:
|
|
88
|
+
reliability: int
|
|
89
|
+
input_tokens: int
|
|
90
|
+
output_tokens: int
|
|
91
|
+
latency: int
|
|
92
|
+
retry: int
|
|
93
|
+
escalation: int
|
|
94
|
+
scarcity: int
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class PolicyGates:
|
|
99
|
+
graph_valid: bool = True
|
|
100
|
+
graph_fresh: bool = True
|
|
101
|
+
registry_fresh: bool = True
|
|
102
|
+
data_policy_allowed: bool = True
|
|
103
|
+
storage_available: bool = True
|
|
104
|
+
task_evaluated: bool = False
|
|
105
|
+
context_tokens: int = 1_000
|
|
106
|
+
budget_tokens: int = 10_000
|
|
107
|
+
current_date: str = "2026-07-14"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class ScoredCandidate:
|
|
112
|
+
model_id: str
|
|
113
|
+
effort: Effort
|
|
114
|
+
score: int
|
|
115
|
+
components: tuple[tuple[str, int], ...]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class PolicyResult:
|
|
120
|
+
selected: ScoredCandidate | None
|
|
121
|
+
ranked: tuple[ScoredCandidate, ...]
|
|
122
|
+
manual_handoff: bool
|
|
123
|
+
recommended_channels: tuple[str, ...]
|
|
124
|
+
reasons: tuple[str, ...]
|
|
125
|
+
policy_version: str = POLICY_VERSION
|
|
126
|
+
evidence_version: str = EVIDENCE_VERSION
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class PromotionEligibility:
|
|
131
|
+
eligible: bool
|
|
132
|
+
lower_bound: float
|
|
133
|
+
reason: str
|
|
134
|
+
required_samples: int | None
|
|
135
|
+
required_lower_bound: float | None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class CliCandidateMetrics:
|
|
140
|
+
capability_snapshot_digest: str
|
|
141
|
+
effort: Effort
|
|
142
|
+
repository_success_millis: int | None
|
|
143
|
+
global_success_millis: int
|
|
144
|
+
sample_count: int
|
|
145
|
+
expected_input_tokens: int
|
|
146
|
+
expected_output_tokens: int
|
|
147
|
+
expected_latency_ms: int
|
|
148
|
+
quota_scarcity_millis: int
|
|
149
|
+
success_count: int | None = None
|
|
150
|
+
latest_observed_at: int | None = None
|
|
151
|
+
cost_status: str = "unknown"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class CliPolicyGates:
|
|
156
|
+
graph_valid: bool = True
|
|
157
|
+
graph_fresh: bool = True
|
|
158
|
+
data_policy_allowed: bool = True
|
|
159
|
+
storage_available: bool = True
|
|
160
|
+
authenticated_providers: tuple[ProviderId, ...] = (
|
|
161
|
+
ProviderId.CLAUDE_CODE,
|
|
162
|
+
ProviderId.CODEX,
|
|
163
|
+
)
|
|
164
|
+
current_cli_identities: tuple[CliIdentity, ...] = ()
|
|
165
|
+
permission_mode: PermissionMode = PermissionMode.WORKSPACE_WRITE
|
|
166
|
+
context_tokens: int = 1_000
|
|
167
|
+
budget_tokens: int = 10_000
|
|
168
|
+
now: int = 150
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class ScoredCliCandidate:
|
|
173
|
+
provider: ProviderId
|
|
174
|
+
requested_model: str
|
|
175
|
+
effective_model: str
|
|
176
|
+
effort: Effort
|
|
177
|
+
capability_snapshot_digest: str
|
|
178
|
+
score: int
|
|
179
|
+
components: tuple[tuple[str, int], ...]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class CliPolicyResult:
|
|
184
|
+
selected: ScoredCliCandidate | None
|
|
185
|
+
ranked: tuple[ScoredCliCandidate, ...]
|
|
186
|
+
manual_handoff: bool
|
|
187
|
+
reasons: tuple[str, ...]
|
|
188
|
+
policy_version: str = "3"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class CliLearnedPolicy:
|
|
193
|
+
policy_version: str
|
|
194
|
+
parent_version: str | None
|
|
195
|
+
scoring_weights: tuple[tuple[str, int], ...]
|
|
196
|
+
minimum_samples: int = CLI_MINIMUM_LEARNING_SAMPLES
|
|
197
|
+
recency_half_life_days: int = CLI_RECENCY_HALF_LIFE_DAYS
|
|
198
|
+
source_policy_version: str = "3"
|
|
199
|
+
|
|
200
|
+
def __post_init__(self) -> None:
|
|
201
|
+
if not isinstance(self.policy_version, str) or not self.policy_version:
|
|
202
|
+
raise ValueError("policy_version_invalid")
|
|
203
|
+
if self.parent_version is not None and (
|
|
204
|
+
not isinstance(self.parent_version, str) or not self.parent_version
|
|
205
|
+
):
|
|
206
|
+
raise ValueError("parent_policy_version_invalid")
|
|
207
|
+
if (
|
|
208
|
+
not isinstance(self.scoring_weights, tuple)
|
|
209
|
+
or not self.scoring_weights
|
|
210
|
+
or len({name for name, _ in self.scoring_weights}) != len(self.scoring_weights)
|
|
211
|
+
):
|
|
212
|
+
raise ValueError("policy_weights_invalid")
|
|
213
|
+
allowed = {"reliability", "tokens", "latency", "scarcity", "effort", "confidence", "recency"}
|
|
214
|
+
for name, value in self.scoring_weights:
|
|
215
|
+
if name not in allowed:
|
|
216
|
+
raise ValueError("policy_weights_invalid")
|
|
217
|
+
_bounded_metric(value, "policy_weights_invalid", 1_000)
|
|
218
|
+
if (
|
|
219
|
+
isinstance(self.minimum_samples, bool)
|
|
220
|
+
or not isinstance(self.minimum_samples, int)
|
|
221
|
+
or not 20 <= self.minimum_samples <= 10_000
|
|
222
|
+
):
|
|
223
|
+
raise ValueError("policy_minimum_samples_invalid")
|
|
224
|
+
if (
|
|
225
|
+
isinstance(self.recency_half_life_days, bool)
|
|
226
|
+
or not isinstance(self.recency_half_life_days, int)
|
|
227
|
+
or not 7 <= self.recency_half_life_days <= 365
|
|
228
|
+
):
|
|
229
|
+
raise ValueError("policy_recency_invalid")
|
|
230
|
+
|
|
231
|
+
def to_payload(self) -> dict[str, object]:
|
|
232
|
+
"""Return fixed guardrails plus tunable ranking weights only."""
|
|
233
|
+
return {
|
|
234
|
+
"schema_version": 1,
|
|
235
|
+
"autonomy": False,
|
|
236
|
+
"allowed_providers": list(_LEARNED_PROVIDERS),
|
|
237
|
+
"permission_ceiling": _LEARNED_PERMISSION_CEILING,
|
|
238
|
+
"risk_ceilings": dict(_LEARNED_RISK_CEILINGS),
|
|
239
|
+
"scoring_weights": dict(sorted(self.scoring_weights)),
|
|
240
|
+
"minimum_samples": self.minimum_samples,
|
|
241
|
+
"recency_half_life_days": self.recency_half_life_days,
|
|
242
|
+
"source_policy_version": self.source_policy_version,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class PolicyComparisonEvidence:
|
|
248
|
+
baseline_version: str
|
|
249
|
+
candidate_version: str
|
|
250
|
+
evidence_hash: str
|
|
251
|
+
sample_count: int
|
|
252
|
+
score_delta_millis: int
|
|
253
|
+
promotion_eligible: bool
|
|
254
|
+
reason: str
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cli_policy_payload_hash(candidate: CliLearnedPolicy) -> str:
|
|
258
|
+
payload = json.dumps(
|
|
259
|
+
candidate.to_payload(), sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
|
260
|
+
).encode("utf-8")
|
|
261
|
+
return hashlib.sha256(payload).hexdigest()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def sign_cli_policy(candidate: CliLearnedPolicy, evidence_hash: str, signing_key: bytes) -> str:
|
|
265
|
+
if not isinstance(signing_key, bytes) or len(signing_key) < 32:
|
|
266
|
+
raise ValueError("policy_signing_key_invalid")
|
|
267
|
+
if not isinstance(evidence_hash, str) or len(evidence_hash) != 64 or any(
|
|
268
|
+
character not in "0123456789abcdef" for character in evidence_hash
|
|
269
|
+
):
|
|
270
|
+
raise ValueError("evidence_hash_invalid")
|
|
271
|
+
signed = json.dumps(
|
|
272
|
+
{
|
|
273
|
+
"evidence_hash": evidence_hash,
|
|
274
|
+
"parent_version": candidate.parent_version,
|
|
275
|
+
"payload_hash": cli_policy_payload_hash(candidate),
|
|
276
|
+
"policy_version": candidate.policy_version,
|
|
277
|
+
},
|
|
278
|
+
sort_keys=True, separators=(",", ":"), ensure_ascii=True,
|
|
279
|
+
).encode("utf-8")
|
|
280
|
+
return hmac.new(signing_key, signed, hashlib.sha256).hexdigest()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def compare_cli_policy_candidate(
|
|
284
|
+
candidate: CliLearnedPolicy,
|
|
285
|
+
*,
|
|
286
|
+
evidence_hash: str,
|
|
287
|
+
sample_count: int,
|
|
288
|
+
baseline_score_millis: int,
|
|
289
|
+
candidate_score_millis: int,
|
|
290
|
+
) -> PolicyComparisonEvidence:
|
|
291
|
+
sample_count = _bounded_metric(sample_count, "sample_count_invalid")
|
|
292
|
+
baseline = _bounded_metric(baseline_score_millis, "baseline_score_invalid", 1_000)
|
|
293
|
+
proposed = _bounded_metric(candidate_score_millis, "candidate_score_invalid", 1_000)
|
|
294
|
+
if len(evidence_hash) != 64 or any(c not in "0123456789abcdef" for c in evidence_hash):
|
|
295
|
+
raise ValueError("evidence_hash_invalid")
|
|
296
|
+
enough = sample_count >= candidate.minimum_samples
|
|
297
|
+
improved = proposed > baseline
|
|
298
|
+
return PolicyComparisonEvidence(
|
|
299
|
+
candidate.source_policy_version,
|
|
300
|
+
candidate.policy_version,
|
|
301
|
+
evidence_hash,
|
|
302
|
+
sample_count,
|
|
303
|
+
proposed - baseline,
|
|
304
|
+
enough and improved,
|
|
305
|
+
"candidate_improves_with_sufficient_evidence"
|
|
306
|
+
if enough and improved
|
|
307
|
+
else "sample_size_insufficient" if not enough else "candidate_not_better",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _global_failures(gates: PolicyGates) -> list[str]:
|
|
312
|
+
failures: list[str] = []
|
|
313
|
+
for allowed, reason in (
|
|
314
|
+
(gates.graph_valid, "graph_invalid"),
|
|
315
|
+
(gates.graph_fresh, "graph_stale"),
|
|
316
|
+
(gates.registry_fresh, "registry_stale"),
|
|
317
|
+
(gates.data_policy_allowed, "data_policy_blocked"),
|
|
318
|
+
(gates.storage_available, "storage_unavailable"),
|
|
319
|
+
):
|
|
320
|
+
if not isinstance(allowed, bool) or not allowed:
|
|
321
|
+
failures.append(reason)
|
|
322
|
+
if (
|
|
323
|
+
isinstance(gates.context_tokens, bool)
|
|
324
|
+
or not isinstance(gates.context_tokens, int)
|
|
325
|
+
or gates.context_tokens < 0
|
|
326
|
+
):
|
|
327
|
+
failures.append("context_invalid")
|
|
328
|
+
if (
|
|
329
|
+
isinstance(gates.budget_tokens, bool)
|
|
330
|
+
or not isinstance(gates.budget_tokens, int)
|
|
331
|
+
or gates.budget_tokens < 0
|
|
332
|
+
):
|
|
333
|
+
failures.append("budget_invalid")
|
|
334
|
+
return failures
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _bounded_metric(value: object, code: str, maximum: int = 10**9) -> int:
|
|
338
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
339
|
+
raise ValueError(code)
|
|
340
|
+
if value < 0 or value > maximum or int(value) != value:
|
|
341
|
+
raise ValueError(code)
|
|
342
|
+
return int(value)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _validate_candidate_metrics(candidate: CandidateMetrics) -> _ValidatedCandidateMetrics:
|
|
346
|
+
global_success = _bounded_metric(
|
|
347
|
+
candidate.global_success_millis,
|
|
348
|
+
"global_success_invalid",
|
|
349
|
+
1_000,
|
|
350
|
+
)
|
|
351
|
+
repository_success = candidate.repository_success_millis
|
|
352
|
+
reliability = (
|
|
353
|
+
global_success
|
|
354
|
+
if repository_success is None
|
|
355
|
+
else _bounded_metric(repository_success, "repository_success_invalid", 1_000)
|
|
356
|
+
)
|
|
357
|
+
return _ValidatedCandidateMetrics(
|
|
358
|
+
reliability=reliability,
|
|
359
|
+
input_tokens=_bounded_metric(
|
|
360
|
+
candidate.expected_input_tokens,
|
|
361
|
+
"expected_input_tokens_invalid",
|
|
362
|
+
),
|
|
363
|
+
output_tokens=_bounded_metric(
|
|
364
|
+
candidate.expected_output_tokens,
|
|
365
|
+
"expected_output_tokens_invalid",
|
|
366
|
+
),
|
|
367
|
+
latency=_bounded_metric(candidate.expected_latency_ms, "expected_latency_invalid"),
|
|
368
|
+
retry=_bounded_metric(candidate.retry_rate_millis, "retry_rate_invalid", 1_000),
|
|
369
|
+
escalation=_bounded_metric(
|
|
370
|
+
candidate.escalation_rate_millis,
|
|
371
|
+
"escalation_rate_invalid",
|
|
372
|
+
1_000,
|
|
373
|
+
),
|
|
374
|
+
scarcity=_bounded_metric(
|
|
375
|
+
candidate.quota_scarcity_millis,
|
|
376
|
+
"quota_scarcity_invalid",
|
|
377
|
+
1_000,
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _accepted_capabilities(category: TaskCategory) -> frozenset[str]:
|
|
383
|
+
if category is TaskCategory.ARCHITECTURE:
|
|
384
|
+
return _ARCHITECTURE_CAPABILITIES
|
|
385
|
+
if category is TaskCategory.REFACTOR:
|
|
386
|
+
return _REFACTOR_CAPABILITIES
|
|
387
|
+
return _CODE_CAPABILITIES
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _candidate_failure(
|
|
391
|
+
task: TaskProfile,
|
|
392
|
+
snapshot: RegistrySnapshot,
|
|
393
|
+
candidate: CandidateMetrics,
|
|
394
|
+
gates: PolicyGates,
|
|
395
|
+
) -> str | None:
|
|
396
|
+
entry = BUNDLED_PROFILES.get(candidate.model_id)
|
|
397
|
+
if entry is None:
|
|
398
|
+
return "model_profile_missing"
|
|
399
|
+
try:
|
|
400
|
+
effort = Effort(candidate.effort)
|
|
401
|
+
except (TypeError, ValueError):
|
|
402
|
+
return "effort_unsupported"
|
|
403
|
+
if effort not in entry.profile.supported_efforts:
|
|
404
|
+
return "effort_unsupported"
|
|
405
|
+
if not profile_is_eligible(candidate.model_id, task.risk):
|
|
406
|
+
return "risk_ineligible"
|
|
407
|
+
if not lifecycle_is_eligible(entry.retirement_date, gates.current_date):
|
|
408
|
+
return "model_retiring"
|
|
409
|
+
inventory_model = next(
|
|
410
|
+
(model for model in snapshot.models if model.model_id == candidate.model_id),
|
|
411
|
+
None,
|
|
412
|
+
)
|
|
413
|
+
if inventory_model is None:
|
|
414
|
+
return "model_unavailable"
|
|
415
|
+
expected_input = _bounded_metric(candidate.expected_input_tokens, "expected_input_tokens_invalid")
|
|
416
|
+
expected_output = _bounded_metric(candidate.expected_output_tokens, "expected_output_tokens_invalid")
|
|
417
|
+
context_limit = min(
|
|
418
|
+
entry.profile.context_window_tokens,
|
|
419
|
+
inventory_model.context_window_tokens or entry.profile.context_window_tokens,
|
|
420
|
+
)
|
|
421
|
+
if max(gates.context_tokens, expected_input) + expected_output > context_limit:
|
|
422
|
+
return "context_exceeded"
|
|
423
|
+
if expected_input + expected_output > gates.budget_tokens:
|
|
424
|
+
return "budget_exceeded"
|
|
425
|
+
if not gates.task_evaluated and not entry.profile.provisional:
|
|
426
|
+
return "task_not_evaluated"
|
|
427
|
+
accepted_capabilities = _accepted_capabilities(task.category)
|
|
428
|
+
if accepted_capabilities.isdisjoint(entry.profile.capabilities):
|
|
429
|
+
return "capability_missing"
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _score(
|
|
434
|
+
candidate: CandidateMetrics,
|
|
435
|
+
task: TaskProfile,
|
|
436
|
+
metrics: _ValidatedCandidateMetrics,
|
|
437
|
+
) -> ScoredCandidate:
|
|
438
|
+
entry = BUNDLED_PROFILES[candidate.model_id]
|
|
439
|
+
token_penalty = min(250, (metrics.input_tokens + metrics.output_tokens) // 100)
|
|
440
|
+
latency_penalty = min(200, metrics.latency // 1_000)
|
|
441
|
+
retry_penalty = metrics.retry // 5
|
|
442
|
+
escalation_penalty = metrics.escalation // 5
|
|
443
|
+
scarcity_penalty = metrics.scarcity // 10
|
|
444
|
+
role_bonuses = _ROLE_BONUSES.get(task.category, {})
|
|
445
|
+
role_fit_bonus = max((role_bonuses.get(role, 0) for role in entry.roles), default=0)
|
|
446
|
+
usage_penalty = _USAGE_PENALTIES[entry.usage_class]
|
|
447
|
+
score = max(
|
|
448
|
+
0,
|
|
449
|
+
min(
|
|
450
|
+
1_000,
|
|
451
|
+
metrics.reliability
|
|
452
|
+
+ role_fit_bonus
|
|
453
|
+
- usage_penalty
|
|
454
|
+
- token_penalty
|
|
455
|
+
- latency_penalty
|
|
456
|
+
- retry_penalty
|
|
457
|
+
- escalation_penalty
|
|
458
|
+
- scarcity_penalty,
|
|
459
|
+
),
|
|
460
|
+
)
|
|
461
|
+
return ScoredCandidate(
|
|
462
|
+
candidate.model_id,
|
|
463
|
+
Effort(candidate.effort),
|
|
464
|
+
score,
|
|
465
|
+
(
|
|
466
|
+
("reliability", metrics.reliability),
|
|
467
|
+
("role_fit_bonus", role_fit_bonus),
|
|
468
|
+
("usage_penalty", usage_penalty),
|
|
469
|
+
("token_penalty", token_penalty),
|
|
470
|
+
("latency_penalty", latency_penalty),
|
|
471
|
+
("retry_penalty", retry_penalty),
|
|
472
|
+
("escalation_penalty", escalation_penalty),
|
|
473
|
+
("quota_scarcity_penalty", scarcity_penalty),
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def rank_candidates(
|
|
479
|
+
task: TaskProfile,
|
|
480
|
+
snapshot: RegistrySnapshot,
|
|
481
|
+
candidates: tuple[CandidateMetrics, ...],
|
|
482
|
+
gates: PolicyGates,
|
|
483
|
+
) -> PolicyResult:
|
|
484
|
+
"""Apply hard gates then rank eligible candidates deterministically."""
|
|
485
|
+
global_failures = _global_failures(gates)
|
|
486
|
+
if global_failures:
|
|
487
|
+
return PolicyResult(
|
|
488
|
+
None,
|
|
489
|
+
(),
|
|
490
|
+
True,
|
|
491
|
+
("claude_code", "codex"),
|
|
492
|
+
tuple(global_failures),
|
|
493
|
+
)
|
|
494
|
+
scored: list[ScoredCandidate] = []
|
|
495
|
+
rejected: list[str] = []
|
|
496
|
+
for candidate in candidates:
|
|
497
|
+
metrics = _validate_candidate_metrics(candidate)
|
|
498
|
+
failure = _candidate_failure(task, snapshot, candidate, gates)
|
|
499
|
+
if failure is not None:
|
|
500
|
+
rejected.append(failure)
|
|
501
|
+
continue
|
|
502
|
+
scored.append(_score(candidate, task, metrics))
|
|
503
|
+
ranked = tuple(
|
|
504
|
+
sorted(scored, key=lambda item: (-item.score, item.model_id, item.effort.value))
|
|
505
|
+
)
|
|
506
|
+
if not ranked:
|
|
507
|
+
return PolicyResult(
|
|
508
|
+
None,
|
|
509
|
+
(),
|
|
510
|
+
True,
|
|
511
|
+
("claude_code", "codex"),
|
|
512
|
+
tuple(sorted(set(rejected))) or ("no_candidates",),
|
|
513
|
+
)
|
|
514
|
+
return PolicyResult(
|
|
515
|
+
ranked[0],
|
|
516
|
+
ranked,
|
|
517
|
+
False,
|
|
518
|
+
(),
|
|
519
|
+
("eligible_ranked",),
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
_RISK_ORDER: Final = {RiskTier.LOW: 0, RiskTier.MEDIUM: 1, RiskTier.HIGH: 2}
|
|
524
|
+
_EFFORT_PENALTY: Final = {
|
|
525
|
+
Effort.DEFAULT: 30,
|
|
526
|
+
Effort.LOW: 0,
|
|
527
|
+
Effort.MEDIUM: 10,
|
|
528
|
+
Effort.HIGH: 30,
|
|
529
|
+
Effort.XHIGH: 60,
|
|
530
|
+
Effort.MAX: 80,
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _cli_global_failures(gates: CliPolicyGates) -> list[str]:
|
|
535
|
+
failures: list[str] = []
|
|
536
|
+
for allowed, reason in (
|
|
537
|
+
(gates.graph_valid, "graph_invalid"),
|
|
538
|
+
(gates.graph_fresh, "graph_stale"),
|
|
539
|
+
(gates.data_policy_allowed, "data_policy_blocked"),
|
|
540
|
+
(gates.storage_available, "storage_unavailable"),
|
|
541
|
+
):
|
|
542
|
+
if not isinstance(allowed, bool) or not allowed:
|
|
543
|
+
failures.append(reason)
|
|
544
|
+
try:
|
|
545
|
+
providers = tuple(ProviderId(item) for item in gates.authenticated_providers)
|
|
546
|
+
PermissionMode(gates.permission_mode)
|
|
547
|
+
except (TypeError, ValueError):
|
|
548
|
+
failures.append("policy_gate_invalid")
|
|
549
|
+
providers = ()
|
|
550
|
+
if len(set(providers)) != len(providers):
|
|
551
|
+
failures.append("policy_gate_invalid")
|
|
552
|
+
if (
|
|
553
|
+
not isinstance(gates.current_cli_identities, tuple)
|
|
554
|
+
or any(not isinstance(item, CliIdentity) for item in gates.current_cli_identities)
|
|
555
|
+
or len({item.provider for item in gates.current_cli_identities})
|
|
556
|
+
!= len(gates.current_cli_identities)
|
|
557
|
+
):
|
|
558
|
+
failures.append("policy_gate_invalid")
|
|
559
|
+
for value, reason in (
|
|
560
|
+
(gates.context_tokens, "context_invalid"),
|
|
561
|
+
(gates.budget_tokens, "budget_invalid"),
|
|
562
|
+
(gates.now, "policy_time_invalid"),
|
|
563
|
+
):
|
|
564
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
565
|
+
failures.append(reason)
|
|
566
|
+
if failures:
|
|
567
|
+
return failures
|
|
568
|
+
return failures
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def rank_cli_candidates(
|
|
572
|
+
task: TaskProfile,
|
|
573
|
+
snapshots: tuple[CapabilitySnapshot, ...],
|
|
574
|
+
candidates: tuple[CliCandidateMetrics, ...],
|
|
575
|
+
gates: CliPolicyGates,
|
|
576
|
+
) -> CliPolicyResult:
|
|
577
|
+
"""Apply provider-neutral hard gates before deterministic CLI profile scoring."""
|
|
578
|
+
failures = _cli_global_failures(gates)
|
|
579
|
+
if failures:
|
|
580
|
+
return CliPolicyResult(None, (), True, tuple(sorted(set(failures))))
|
|
581
|
+
snapshot_by_digest: dict[str, CapabilitySnapshot] = {}
|
|
582
|
+
for snapshot in snapshots:
|
|
583
|
+
if not isinstance(snapshot, CapabilitySnapshot) or snapshot.digest in snapshot_by_digest:
|
|
584
|
+
raise ValueError("capability_snapshot_invalid")
|
|
585
|
+
snapshot_by_digest[snapshot.digest] = snapshot
|
|
586
|
+
authenticated = {ProviderId(item) for item in gates.authenticated_providers}
|
|
587
|
+
current_identities = {item.provider: item for item in gates.current_cli_identities}
|
|
588
|
+
required_permission = PermissionMode(gates.permission_mode)
|
|
589
|
+
scored: list[ScoredCliCandidate] = []
|
|
590
|
+
rejected: list[str] = []
|
|
591
|
+
for candidate in candidates:
|
|
592
|
+
if not isinstance(candidate, CliCandidateMetrics):
|
|
593
|
+
raise ValueError("candidate_invalid")
|
|
594
|
+
snapshot = snapshot_by_digest.get(candidate.capability_snapshot_digest)
|
|
595
|
+
if snapshot is None:
|
|
596
|
+
rejected.append("capability_snapshot_missing")
|
|
597
|
+
continue
|
|
598
|
+
profile = snapshot.profile
|
|
599
|
+
if snapshot.expires_at <= gates.now:
|
|
600
|
+
rejected.append("capability_snapshot_expired")
|
|
601
|
+
continue
|
|
602
|
+
if profile.provider not in authenticated:
|
|
603
|
+
rejected.append("provider_unauthenticated")
|
|
604
|
+
continue
|
|
605
|
+
if current_identities.get(profile.provider) != snapshot.identity:
|
|
606
|
+
rejected.append("cli_identity_changed")
|
|
607
|
+
continue
|
|
608
|
+
if profile.permission_mode is not required_permission:
|
|
609
|
+
rejected.append("permission_mismatch")
|
|
610
|
+
continue
|
|
611
|
+
try:
|
|
612
|
+
effort = Effort(candidate.effort)
|
|
613
|
+
except (TypeError, ValueError):
|
|
614
|
+
rejected.append("effort_unsupported")
|
|
615
|
+
continue
|
|
616
|
+
if effort not in profile.supported_efforts:
|
|
617
|
+
rejected.append("effort_unsupported")
|
|
618
|
+
continue
|
|
619
|
+
if _RISK_ORDER[task.risk] > _RISK_ORDER[profile.risk_ceiling]:
|
|
620
|
+
rejected.append("risk_ineligible")
|
|
621
|
+
continue
|
|
622
|
+
input_tokens = _bounded_metric(
|
|
623
|
+
candidate.expected_input_tokens, "expected_input_tokens_invalid"
|
|
624
|
+
)
|
|
625
|
+
output_tokens = _bounded_metric(
|
|
626
|
+
candidate.expected_output_tokens, "expected_output_tokens_invalid"
|
|
627
|
+
)
|
|
628
|
+
if max(gates.context_tokens, input_tokens) + output_tokens > profile.context_window_tokens:
|
|
629
|
+
rejected.append("context_exceeded")
|
|
630
|
+
continue
|
|
631
|
+
if input_tokens + output_tokens > gates.budget_tokens:
|
|
632
|
+
rejected.append("budget_exceeded")
|
|
633
|
+
continue
|
|
634
|
+
if _accepted_capabilities(task.category).isdisjoint(profile.capabilities):
|
|
635
|
+
rejected.append("capability_missing")
|
|
636
|
+
continue
|
|
637
|
+
sample_count = _bounded_metric(candidate.sample_count, "sample_count_invalid")
|
|
638
|
+
if candidate.cost_status != "unknown":
|
|
639
|
+
raise ValueError("cost_status_invalid")
|
|
640
|
+
global_success = _bounded_metric(
|
|
641
|
+
candidate.global_success_millis, "global_success_invalid", 1_000
|
|
642
|
+
)
|
|
643
|
+
repository_success = candidate.repository_success_millis
|
|
644
|
+
reliability = global_success
|
|
645
|
+
if sample_count >= 5 and repository_success is not None:
|
|
646
|
+
reliability = _bounded_metric(
|
|
647
|
+
repository_success, "repository_success_invalid", 1_000
|
|
648
|
+
)
|
|
649
|
+
if candidate.success_count is None:
|
|
650
|
+
confidence_penalty = 150
|
|
651
|
+
else:
|
|
652
|
+
successes = _bounded_metric(candidate.success_count, "success_count_invalid")
|
|
653
|
+
if successes > sample_count:
|
|
654
|
+
raise ValueError("success_count_invalid")
|
|
655
|
+
confidence_penalty = (
|
|
656
|
+
150
|
|
657
|
+
if sample_count < CLI_MINIMUM_LEARNING_SAMPLES
|
|
658
|
+
else min(200, round((1 - wilson_lower_bound(successes, sample_count)) * 200))
|
|
659
|
+
)
|
|
660
|
+
if candidate.latest_observed_at is None:
|
|
661
|
+
recency_penalty = 50
|
|
662
|
+
else:
|
|
663
|
+
latest = _bounded_metric(candidate.latest_observed_at, "latest_observed_at_invalid")
|
|
664
|
+
if latest > gates.now:
|
|
665
|
+
raise ValueError("latest_observed_at_invalid")
|
|
666
|
+
age_days = (gates.now - latest) // 86_400
|
|
667
|
+
recency_penalty = min(100, age_days * 2)
|
|
668
|
+
latency = _bounded_metric(candidate.expected_latency_ms, "expected_latency_invalid")
|
|
669
|
+
scarcity = _bounded_metric(
|
|
670
|
+
candidate.quota_scarcity_millis, "quota_scarcity_invalid", 1_000
|
|
671
|
+
)
|
|
672
|
+
token_penalty = min(250, (input_tokens + output_tokens) // 100)
|
|
673
|
+
latency_penalty = min(200, latency // 1_000)
|
|
674
|
+
scarcity_penalty = scarcity // 10
|
|
675
|
+
effort_penalty = _EFFORT_PENALTY[effort]
|
|
676
|
+
cost_unknown_penalty = 25
|
|
677
|
+
score = max(
|
|
678
|
+
0,
|
|
679
|
+
min(
|
|
680
|
+
1_000,
|
|
681
|
+
reliability
|
|
682
|
+
- token_penalty
|
|
683
|
+
- latency_penalty
|
|
684
|
+
- scarcity_penalty
|
|
685
|
+
- effort_penalty
|
|
686
|
+
- confidence_penalty
|
|
687
|
+
- recency_penalty
|
|
688
|
+
- cost_unknown_penalty,
|
|
689
|
+
),
|
|
690
|
+
)
|
|
691
|
+
scored.append(
|
|
692
|
+
ScoredCliCandidate(
|
|
693
|
+
profile.provider,
|
|
694
|
+
profile.requested_model,
|
|
695
|
+
profile.effective_model,
|
|
696
|
+
effort,
|
|
697
|
+
snapshot.digest,
|
|
698
|
+
score,
|
|
699
|
+
(
|
|
700
|
+
("reliability", reliability),
|
|
701
|
+
("token_penalty", token_penalty),
|
|
702
|
+
("latency_penalty", latency_penalty),
|
|
703
|
+
("quota_scarcity_penalty", scarcity_penalty),
|
|
704
|
+
("effort_penalty", effort_penalty),
|
|
705
|
+
("confidence_penalty", confidence_penalty),
|
|
706
|
+
("recency_penalty", recency_penalty),
|
|
707
|
+
("cost_unknown_penalty", cost_unknown_penalty),
|
|
708
|
+
),
|
|
709
|
+
)
|
|
710
|
+
)
|
|
711
|
+
ranked = tuple(
|
|
712
|
+
sorted(
|
|
713
|
+
scored,
|
|
714
|
+
key=lambda item: (
|
|
715
|
+
-item.score,
|
|
716
|
+
item.provider.value,
|
|
717
|
+
item.effective_model,
|
|
718
|
+
item.effort.value,
|
|
719
|
+
item.capability_snapshot_digest,
|
|
720
|
+
),
|
|
721
|
+
)
|
|
722
|
+
)
|
|
723
|
+
if not ranked:
|
|
724
|
+
return CliPolicyResult(
|
|
725
|
+
None,
|
|
726
|
+
(),
|
|
727
|
+
True,
|
|
728
|
+
tuple(sorted(set(rejected))) or ("no_candidates",),
|
|
729
|
+
)
|
|
730
|
+
return CliPolicyResult(ranked[0], ranked, False, ("eligible_ranked",))
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def wilson_lower_bound(successes: int, total: int) -> float:
|
|
734
|
+
"""Return the two-sided 95% Wilson score lower bound."""
|
|
735
|
+
if (
|
|
736
|
+
isinstance(successes, bool)
|
|
737
|
+
or isinstance(total, bool)
|
|
738
|
+
or not isinstance(successes, int)
|
|
739
|
+
or not isinstance(total, int)
|
|
740
|
+
or successes < 0
|
|
741
|
+
or total < 0
|
|
742
|
+
or successes > total
|
|
743
|
+
):
|
|
744
|
+
raise ValueError("confidence_counts_invalid")
|
|
745
|
+
if total == 0:
|
|
746
|
+
return 0.0
|
|
747
|
+
proportion = successes / total
|
|
748
|
+
z_squared = _Z_95 * _Z_95
|
|
749
|
+
denominator = 1 + z_squared / total
|
|
750
|
+
center = proportion + z_squared / (2 * total)
|
|
751
|
+
margin = _Z_95 * math.sqrt(
|
|
752
|
+
(proportion * (1 - proportion) + z_squared / (4 * total)) / total
|
|
753
|
+
)
|
|
754
|
+
return max(0.0, (center - margin) / denominator)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def promotion_eligibility(
|
|
758
|
+
risk: RiskTier | str,
|
|
759
|
+
*,
|
|
760
|
+
successes: int,
|
|
761
|
+
total: int,
|
|
762
|
+
severe_failures: int,
|
|
763
|
+
) -> PromotionEligibility:
|
|
764
|
+
"""Evaluate evidence only; this function never grants execution authority."""
|
|
765
|
+
normalized = RiskTier(risk)
|
|
766
|
+
lower = wilson_lower_bound(successes, total)
|
|
767
|
+
if (
|
|
768
|
+
isinstance(severe_failures, bool)
|
|
769
|
+
or not isinstance(severe_failures, int)
|
|
770
|
+
or severe_failures < 0
|
|
771
|
+
or severe_failures > total
|
|
772
|
+
):
|
|
773
|
+
raise ValueError("severe_failure_count_invalid")
|
|
774
|
+
if normalized is RiskTier.HIGH:
|
|
775
|
+
return PromotionEligibility(
|
|
776
|
+
False,
|
|
777
|
+
lower,
|
|
778
|
+
"high_risk_permanent_gate",
|
|
779
|
+
None,
|
|
780
|
+
None,
|
|
781
|
+
)
|
|
782
|
+
required_samples, required_lower = (
|
|
783
|
+
(50, 0.90) if normalized is RiskTier.LOW else (100, 0.95)
|
|
784
|
+
)
|
|
785
|
+
if severe_failures:
|
|
786
|
+
return PromotionEligibility(
|
|
787
|
+
False,
|
|
788
|
+
lower,
|
|
789
|
+
"severe_failure_open",
|
|
790
|
+
required_samples,
|
|
791
|
+
required_lower,
|
|
792
|
+
)
|
|
793
|
+
if total < required_samples:
|
|
794
|
+
return PromotionEligibility(
|
|
795
|
+
False,
|
|
796
|
+
lower,
|
|
797
|
+
"sample_size_insufficient",
|
|
798
|
+
required_samples,
|
|
799
|
+
required_lower,
|
|
800
|
+
)
|
|
801
|
+
if lower < required_lower:
|
|
802
|
+
return PromotionEligibility(
|
|
803
|
+
False,
|
|
804
|
+
lower,
|
|
805
|
+
"confidence_insufficient",
|
|
806
|
+
required_samples,
|
|
807
|
+
required_lower,
|
|
808
|
+
)
|
|
809
|
+
return PromotionEligibility(
|
|
810
|
+
True,
|
|
811
|
+
lower,
|
|
812
|
+
"evidence_threshold_met",
|
|
813
|
+
required_samples,
|
|
814
|
+
required_lower,
|
|
815
|
+
)
|