skillpool 4.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.
- skillpool/__init__.py +74 -0
- skillpool/__main__.py +6 -0
- skillpool/adapters/__init__.py +8 -0
- skillpool/adapters/base.py +41 -0
- skillpool/adapters/claude_adapter.py +36 -0
- skillpool/adapters/codex_adapter.py +92 -0
- skillpool/adapters/hermes_adapter.py +38 -0
- skillpool/audit/__init__.py +651 -0
- skillpool/bridge/__init__.py +16 -0
- skillpool/bridge/freeze_detector.py +134 -0
- skillpool/bridge/maintenance.py +119 -0
- skillpool/bridge/wal_manager.py +136 -0
- skillpool/clawmem_client.py +176 -0
- skillpool/cli.py +700 -0
- skillpool/combiner/__init__.py +31 -0
- skillpool/combiner/lifecycle.py +453 -0
- skillpool/combiner/models.py +99 -0
- skillpool/config.py +34 -0
- skillpool/cost/__init__.py +111 -0
- skillpool/cost/audit_hash.py +51 -0
- skillpool/cost/budget_tracker.py +66 -0
- skillpool/cost/dashboard.py +189 -0
- skillpool/cost/models.py +129 -0
- skillpool/cost/token_governor.py +264 -0
- skillpool/cost/trace_ceiling.py +38 -0
- skillpool/csdf.py +126 -0
- skillpool/evolver/__init__.py +978 -0
- skillpool/gain/__init__.py +285 -0
- skillpool/gate.py +282 -0
- skillpool/gate_policy/__init__.py +31 -0
- skillpool/gate_policy/incremental.py +157 -0
- skillpool/gate_policy/parser.py +258 -0
- skillpool/gate_policy/state_machine.py +432 -0
- skillpool/graph/__init__.py +14 -0
- skillpool/graph/ppr.py +279 -0
- skillpool/health/__init__.py +73 -0
- skillpool/health/check.py +85 -0
- skillpool/health/degradation.py +90 -0
- skillpool/health/models.py +43 -0
- skillpool/hooks/__init__.py +4 -0
- skillpool/hooks/security_scanner.py +288 -0
- skillpool/lifecycle.py +150 -0
- skillpool/materializer/__init__.py +124 -0
- skillpool/materializer/budget_cropper.py +178 -0
- skillpool/materializer/csdf_loader.py +114 -0
- skillpool/materializer/lazy_loader.py +265 -0
- skillpool/materializer/lifecycle_filter.py +93 -0
- skillpool/materializer/mapper.py +178 -0
- skillpool/materializer/models.py +66 -0
- skillpool/mcp_server.py +2005 -0
- skillpool/monitor/__init__.py +576 -0
- skillpool/monitor/bug_collector.py +392 -0
- skillpool/monitor/defect_classifier.py +218 -0
- skillpool/monitor/self_healing.py +530 -0
- skillpool/monitor/telemetry_bridge.py +197 -0
- skillpool/paradigm/__init__.py +312 -0
- skillpool/paradigm/override.py +285 -0
- skillpool/profile.py +94 -0
- skillpool/quality.py +254 -0
- skillpool/registry/__init__.py +509 -0
- skillpool/registry/models.py +98 -0
- skillpool/resolver/__init__.py +320 -0
- skillpool/resolver/cache.py +103 -0
- skillpool/resolver/circuit_breaker.py +103 -0
- skillpool/resolver/conflict_detector.py +111 -0
- skillpool/resolver/health_filter.py +38 -0
- skillpool/resolver/models.py +154 -0
- skillpool/resolver/rate_limiter.py +48 -0
- skillpool/resolver/skill_graph.py +183 -0
- skillpool/review/__init__.py +242 -0
- skillpool/review/async_queue.py +96 -0
- skillpool/review/checkpoint_runner.py +345 -0
- skillpool/review/models.py +164 -0
- skillpool/review/suspect_marker.py +39 -0
- skillpool/review/veto_evaluator.py +94 -0
- skillpool/router/__init__.py +481 -0
- skillpool/schemas.py +119 -0
- skillpool/synergy/__init__.py +240 -0
- skillpool/synergy/detector.py +5 -0
- skillpool/telemetry.py +126 -0
- skillpool/utils/__init__.py +21 -0
- skillpool/utils/changelog.py +218 -0
- skillpool/utils/logger.py +273 -0
- skillpool/utils/runtime_audit.py +163 -0
- skillpool/utils/time_utils.py +13 -0
- skillpool-4.3.0.dist-info/METADATA +21 -0
- skillpool-4.3.0.dist-info/RECORD +90 -0
- skillpool-4.3.0.dist-info/WHEEL +5 -0
- skillpool-4.3.0.dist-info/entry_points.txt +3 -0
- skillpool-4.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""SkillResolver — skill chain resolution engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from skillpool.resolver.models import (
|
|
10
|
+
Conflict,
|
|
11
|
+
ConflictSeverity,
|
|
12
|
+
ConflictType as ConflictType,
|
|
13
|
+
DagEdge,
|
|
14
|
+
DagEdgeType,
|
|
15
|
+
Domain as Domain,
|
|
16
|
+
ResolveStatus,
|
|
17
|
+
ResolveStrategy as ResolveStrategy,
|
|
18
|
+
ResolvedSkill,
|
|
19
|
+
SkillResolveRequest,
|
|
20
|
+
SkillResolveResponse,
|
|
21
|
+
)
|
|
22
|
+
from skillpool.resolver.skill_graph import CycleDetected, SkillGraph
|
|
23
|
+
from skillpool.resolver.conflict_detector import ConflictDetector
|
|
24
|
+
from skillpool.resolver.health_filter import HealthFilter
|
|
25
|
+
from skillpool.resolver.cache import LRUCache
|
|
26
|
+
from skillpool.resolver.circuit_breaker import CircuitBreaker, CircuitState
|
|
27
|
+
from skillpool.resolver.rate_limiter import RateLimiter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Simulated skill registry for resolver (production: backed by Registry)
|
|
31
|
+
_SKILL_REGISTRY: dict[str, dict] = {}
|
|
32
|
+
_REGISTRY_LOCK = threading.Lock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register_skill(skill_id: str, data: dict) -> None:
|
|
36
|
+
"""Register a skill in the resolver's local registry."""
|
|
37
|
+
with _REGISTRY_LOCK:
|
|
38
|
+
_SKILL_REGISTRY[skill_id] = data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clear_registry() -> None:
|
|
42
|
+
"""Clear the resolver's local registry."""
|
|
43
|
+
with _REGISTRY_LOCK:
|
|
44
|
+
_SKILL_REGISTRY.clear()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SkillResolver:
|
|
48
|
+
"""Resolve skill chains from CSDF definitions.
|
|
49
|
+
|
|
50
|
+
Pipeline:
|
|
51
|
+
1. Fetch skills from registry
|
|
52
|
+
2. Build dependency DAG
|
|
53
|
+
3. Cycle detection
|
|
54
|
+
4. Conflict detection (Jaccard)
|
|
55
|
+
5. Health filter
|
|
56
|
+
6. Topological sort
|
|
57
|
+
7. Apply constraints (max_skills, exclude, require_independent)
|
|
58
|
+
8. Return resolved chain
|
|
59
|
+
|
|
60
|
+
When a Registry is provided, skill metadata and lifecycle state are
|
|
61
|
+
sourced from the Registry instead of the in-memory dict. The Registry
|
|
62
|
+
provides proper 9-state lifecycle governance, supply chain evidence
|
|
63
|
+
verification, and persistent storage.
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
resolver = SkillResolver()
|
|
67
|
+
response = resolver.resolve(SkillResolveRequest(skill_ids=["S01", "S05a"]))
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
skill_registry: Optional[dict[str, dict]] = None,
|
|
73
|
+
cache_max_size: int = 128,
|
|
74
|
+
cache_ttl: float = 3600.0,
|
|
75
|
+
circuit_failure_threshold: int = 5,
|
|
76
|
+
circuit_recovery_timeout: float = 30.0,
|
|
77
|
+
rate_max_requests: int = 100,
|
|
78
|
+
rate_window_seconds: float = 1.0,
|
|
79
|
+
conflict_threshold: float = 0.5,
|
|
80
|
+
min_health_score: float = 0.6,
|
|
81
|
+
registry=None,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._registry_store = skill_registry if skill_registry is not None else _SKILL_REGISTRY
|
|
84
|
+
self._cache = LRUCache(max_size=cache_max_size, ttl_seconds=cache_ttl)
|
|
85
|
+
self._circuit = CircuitBreaker(
|
|
86
|
+
failure_threshold=circuit_failure_threshold,
|
|
87
|
+
recovery_timeout=circuit_recovery_timeout,
|
|
88
|
+
)
|
|
89
|
+
self._limiter = RateLimiter(
|
|
90
|
+
max_requests=rate_max_requests,
|
|
91
|
+
window_seconds=rate_window_seconds,
|
|
92
|
+
)
|
|
93
|
+
self._conflict_threshold = conflict_threshold
|
|
94
|
+
self._min_health_score = min_health_score
|
|
95
|
+
self._registry = registry
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def circuit_state(self) -> CircuitState:
|
|
99
|
+
return self._circuit.state
|
|
100
|
+
|
|
101
|
+
def resolve(self, request: SkillResolveRequest) -> SkillResolveResponse:
|
|
102
|
+
"""Resolve a skill chain from the request."""
|
|
103
|
+
start = time.monotonic()
|
|
104
|
+
|
|
105
|
+
# Rate limit check
|
|
106
|
+
if not self._limiter.allow():
|
|
107
|
+
return SkillResolveResponse(
|
|
108
|
+
error="rate_limit_exceeded",
|
|
109
|
+
degraded=True,
|
|
110
|
+
resolution_time_ms=(time.monotonic() - start) * 1000,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Circuit breaker check
|
|
114
|
+
if not self._circuit.allow_request():
|
|
115
|
+
return SkillResolveResponse(
|
|
116
|
+
degraded=True,
|
|
117
|
+
error="circuit_open",
|
|
118
|
+
resolution_time_ms=(time.monotonic() - start) * 1000,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Cache check
|
|
122
|
+
cache_key = LRUCache.make_key(
|
|
123
|
+
request.skill_ids,
|
|
124
|
+
strategy=request.strategy.value,
|
|
125
|
+
max_skills=request.max_skills,
|
|
126
|
+
exclude=request.exclude_skills,
|
|
127
|
+
)
|
|
128
|
+
cached = self._cache.get(cache_key)
|
|
129
|
+
if cached is not None:
|
|
130
|
+
cached.from_cache = True
|
|
131
|
+
cached.resolution_time_ms = (time.monotonic() - start) * 1000
|
|
132
|
+
return cached
|
|
133
|
+
|
|
134
|
+
# Resolve
|
|
135
|
+
try:
|
|
136
|
+
response = self._do_resolve(request)
|
|
137
|
+
self._circuit.record_success()
|
|
138
|
+
except CycleDetected as e:
|
|
139
|
+
self._circuit.record_failure()
|
|
140
|
+
return SkillResolveResponse(
|
|
141
|
+
error=str(e),
|
|
142
|
+
resolution_time_ms=(time.monotonic() - start) * 1000,
|
|
143
|
+
)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
self._circuit.record_failure()
|
|
146
|
+
return SkillResolveResponse(
|
|
147
|
+
error=str(e),
|
|
148
|
+
degraded=True,
|
|
149
|
+
resolution_time_ms=(time.monotonic() - start) * 1000,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Cache result
|
|
153
|
+
self._cache.put(cache_key, response)
|
|
154
|
+
response.resolution_time_ms = (time.monotonic() - start) * 1000
|
|
155
|
+
return response
|
|
156
|
+
|
|
157
|
+
def _do_resolve(self, request: SkillResolveRequest) -> SkillResolveResponse:
|
|
158
|
+
"""Core resolution logic."""
|
|
159
|
+
# 1. Fetch skills
|
|
160
|
+
fetched = self._fetch_skills(request.skill_ids)
|
|
161
|
+
if not fetched:
|
|
162
|
+
return SkillResolveResponse(
|
|
163
|
+
error="no_skills_found",
|
|
164
|
+
status=ResolveStatus.UNRESOLVED,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# 2. Build DAG
|
|
168
|
+
graph = self._build_dag(fetched)
|
|
169
|
+
|
|
170
|
+
# 3. Cycle detection
|
|
171
|
+
graph.topological_sort() # raises CycleDetected
|
|
172
|
+
|
|
173
|
+
# 4. Conflict detection
|
|
174
|
+
conflict_detector = ConflictDetector(threshold=self._conflict_threshold)
|
|
175
|
+
for sid, sdata in fetched.items():
|
|
176
|
+
conflict_detector.register(
|
|
177
|
+
skill_id=sid,
|
|
178
|
+
name=sdata.get("name", ""),
|
|
179
|
+
dimension=sdata.get("dimension", ""),
|
|
180
|
+
namespaces=sdata.get("namespaces", []),
|
|
181
|
+
)
|
|
182
|
+
raw_conflicts = conflict_detector.detect()
|
|
183
|
+
|
|
184
|
+
# 5. Health filter
|
|
185
|
+
skills_list = [{"skill_id": sid, **sdata} for sid, sdata in fetched.items()]
|
|
186
|
+
hf = HealthFilter(min_score=request.min_health_score)
|
|
187
|
+
passed, excluded = hf.filter(skills_list)
|
|
188
|
+
|
|
189
|
+
# 6. Apply exclude filter
|
|
190
|
+
if request.exclude_skills:
|
|
191
|
+
passed = [s for s in passed if s["skill_id"] not in request.exclude_skills]
|
|
192
|
+
excluded.extend(request.exclude_skills)
|
|
193
|
+
|
|
194
|
+
# 7. Topological sort
|
|
195
|
+
passed_ids = {s["skill_id"] for s in passed}
|
|
196
|
+
subgraph = graph.subgraph(passed_ids)
|
|
197
|
+
sorted_ids = subgraph.topological_sort()
|
|
198
|
+
|
|
199
|
+
# 8. Apply max_skills constraint
|
|
200
|
+
sorted_ids = sorted_ids[: request.max_skills]
|
|
201
|
+
|
|
202
|
+
# 9. Build response
|
|
203
|
+
resolved = []
|
|
204
|
+
for sid in sorted_ids:
|
|
205
|
+
sdata = fetched.get(sid, {})
|
|
206
|
+
# Find conflict severity for this skill
|
|
207
|
+
conflict_sev = None
|
|
208
|
+
for c in raw_conflicts:
|
|
209
|
+
if c["skill_a"] == sid or c["skill_b"] == sid:
|
|
210
|
+
conflict_sev = ConflictSeverity(c["severity"])
|
|
211
|
+
break
|
|
212
|
+
|
|
213
|
+
resolved.append(
|
|
214
|
+
ResolvedSkill(
|
|
215
|
+
skill_id=sid,
|
|
216
|
+
name=sdata.get("name", ""),
|
|
217
|
+
version=sdata.get("version", "1.0.0"),
|
|
218
|
+
dimension=sdata.get("dimension", ""),
|
|
219
|
+
domain=sdata.get("domain", ""),
|
|
220
|
+
weight=sdata.get("weight", 0.0),
|
|
221
|
+
health_score=sdata.get("health_score", 1.0),
|
|
222
|
+
trust_level=sdata.get("trust_level", 3),
|
|
223
|
+
dependencies=sdata.get("dependencies", []),
|
|
224
|
+
estimated_tokens=sdata.get("estimated_tokens", 0),
|
|
225
|
+
provides=sdata.get("provides", []),
|
|
226
|
+
conflict=conflict_sev,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
conflicts = [Conflict(**c) for c in raw_conflicts]
|
|
231
|
+
|
|
232
|
+
dag_edges = [
|
|
233
|
+
DagEdge(source=src, target=tgt, weight=w, type=DagEdgeType.DEPENDS_ON)
|
|
234
|
+
for src, tgt, w in graph.get_edges()
|
|
235
|
+
if src in passed_ids and tgt in passed_ids
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
# Build health_scores mapping
|
|
239
|
+
health_scores = {s.skill_id: s.health_score for s in resolved}
|
|
240
|
+
|
|
241
|
+
# Calculate feasibility_score = f(health_scores, conflicts)
|
|
242
|
+
avg_health = sum(health_scores.values()) / len(health_scores) if health_scores else 0.0
|
|
243
|
+
conflict_penalty = len(conflicts) * 0.1 # Each conflict reduces feasibility by 0.1
|
|
244
|
+
feasibility_score = max(0.0, min(1.0, avg_health - conflict_penalty))
|
|
245
|
+
|
|
246
|
+
# Determine status
|
|
247
|
+
if len(resolved) == 0:
|
|
248
|
+
status = ResolveStatus.UNRESOLVED
|
|
249
|
+
elif len(resolved) < len(request.skill_ids):
|
|
250
|
+
status = ResolveStatus.PARTIAL
|
|
251
|
+
else:
|
|
252
|
+
status = ResolveStatus.RESOLVED
|
|
253
|
+
|
|
254
|
+
return SkillResolveResponse(
|
|
255
|
+
resolved=resolved,
|
|
256
|
+
conflicts=conflicts,
|
|
257
|
+
excluded=excluded,
|
|
258
|
+
dag_edges=dag_edges,
|
|
259
|
+
total_skills=len(resolved),
|
|
260
|
+
status=status,
|
|
261
|
+
health_scores=health_scores,
|
|
262
|
+
feasibility_score=feasibility_score,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _fetch_skills(self, skill_ids: list[str]) -> dict[str, dict]:
|
|
266
|
+
"""Fetch skills from registry, including transitive dependencies.
|
|
267
|
+
|
|
268
|
+
When a Registry is configured, reads skill metadata from the Registry
|
|
269
|
+
(the authoritative source) and skips skills that are not enabled.
|
|
270
|
+
Otherwise falls back to the in-memory dict store.
|
|
271
|
+
"""
|
|
272
|
+
fetched: dict[str, dict] = {}
|
|
273
|
+
queue = list(skill_ids)
|
|
274
|
+
|
|
275
|
+
while queue:
|
|
276
|
+
sid = queue.pop(0)
|
|
277
|
+
if sid in fetched:
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
# Try Registry first (authoritative source)
|
|
281
|
+
if self._registry is not None:
|
|
282
|
+
record = self._registry.get_skill(sid)
|
|
283
|
+
if record is None:
|
|
284
|
+
continue
|
|
285
|
+
# Skip non-enabled skills from Registry
|
|
286
|
+
if not self._registry.is_enabled(sid):
|
|
287
|
+
continue
|
|
288
|
+
meta = record.metadata
|
|
289
|
+
sdata = {
|
|
290
|
+
"name": meta.name,
|
|
291
|
+
"version": meta.version,
|
|
292
|
+
"dimension": "",
|
|
293
|
+
"namespaces": meta.tags,
|
|
294
|
+
"dependencies": meta.dependencies,
|
|
295
|
+
"weight": meta.quality_score,
|
|
296
|
+
"health_score": 1.0, # Enabled = healthy
|
|
297
|
+
"trust_level": 3,
|
|
298
|
+
}
|
|
299
|
+
else:
|
|
300
|
+
sdata = self._registry_store.get(sid)
|
|
301
|
+
if sdata is None:
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
fetched[sid] = sdata
|
|
305
|
+
# Follow dependencies
|
|
306
|
+
for dep in sdata.get("dependencies", []):
|
|
307
|
+
if dep not in fetched:
|
|
308
|
+
queue.append(dep)
|
|
309
|
+
|
|
310
|
+
return fetched
|
|
311
|
+
|
|
312
|
+
def _build_dag(self, skills: dict[str, dict]) -> SkillGraph:
|
|
313
|
+
"""Build a SkillGraph from skill definitions."""
|
|
314
|
+
graph = SkillGraph()
|
|
315
|
+
for sid, sdata in skills.items():
|
|
316
|
+
graph.add_node(sid)
|
|
317
|
+
for dep in sdata.get("dependencies", []):
|
|
318
|
+
if dep in skills:
|
|
319
|
+
graph.add_edge(dep, sid, weight=sdata.get("weight", 1.0))
|
|
320
|
+
return graph
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Resolver cache — LRU with TTL expiration and thread safety.
|
|
2
|
+
|
|
3
|
+
Each cache entry has a TTL (default 3600s). Entries past TTL are treated
|
|
4
|
+
as misses and lazily evicted on access.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CacheEntry:
|
|
20
|
+
"""A cached value with expiration timestamp."""
|
|
21
|
+
|
|
22
|
+
value: Any
|
|
23
|
+
expires_at: float # time.monotonic() timestamp
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LRUCache:
|
|
27
|
+
"""LRU cache with TTL expiration and thread-safe operations.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
max_size: Maximum number of entries.
|
|
31
|
+
ttl_seconds: Time-to-live for each entry (default 3600s).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, max_size: int = 128, ttl_seconds: float = 3600.0) -> None:
|
|
35
|
+
self._max_size = max_size
|
|
36
|
+
self._ttl_seconds = ttl_seconds
|
|
37
|
+
self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
|
|
38
|
+
self._hits = 0
|
|
39
|
+
self._misses = 0
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def make_key(skill_ids: list[str], **kwargs: object) -> str:
|
|
44
|
+
"""Generate a deterministic cache key from skill_ids and kwargs."""
|
|
45
|
+
payload = json.dumps({"ids": sorted(skill_ids), **kwargs}, sort_keys=True, default=str)
|
|
46
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
47
|
+
|
|
48
|
+
def get(self, key: str) -> Optional[Any]:
|
|
49
|
+
"""Get a cached value. Returns None on miss or TTL expiry."""
|
|
50
|
+
with self._lock:
|
|
51
|
+
entry = self._cache.get(key)
|
|
52
|
+
if entry is None:
|
|
53
|
+
self._misses += 1
|
|
54
|
+
return None
|
|
55
|
+
# Check TTL
|
|
56
|
+
if time.monotonic() > entry.expires_at:
|
|
57
|
+
del self._cache[key]
|
|
58
|
+
self._misses += 1
|
|
59
|
+
return None
|
|
60
|
+
# Move to end (most recently used)
|
|
61
|
+
self._cache.move_to_end(key)
|
|
62
|
+
self._hits += 1
|
|
63
|
+
return entry.value
|
|
64
|
+
|
|
65
|
+
def put(self, key: str, value: Any, ttl_seconds: Optional[float] = None) -> None:
|
|
66
|
+
"""Store a value with optional per-entry TTL override."""
|
|
67
|
+
with self._lock:
|
|
68
|
+
ttl = ttl_seconds if ttl_seconds is not None else self._ttl_seconds
|
|
69
|
+
entry = CacheEntry(value=value, expires_at=time.monotonic() + ttl)
|
|
70
|
+
if key in self._cache:
|
|
71
|
+
self._cache.move_to_end(key)
|
|
72
|
+
self._cache[key] = entry
|
|
73
|
+
# Evict oldest if over capacity
|
|
74
|
+
while len(self._cache) > self._max_size:
|
|
75
|
+
self._cache.popitem(last=False)
|
|
76
|
+
|
|
77
|
+
def invalidate(self, key: str) -> bool:
|
|
78
|
+
"""Remove a specific key. Returns True if key existed."""
|
|
79
|
+
with self._lock:
|
|
80
|
+
if key in self._cache:
|
|
81
|
+
del self._cache[key]
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def clear(self) -> None:
|
|
86
|
+
"""Clear all entries."""
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._cache.clear()
|
|
89
|
+
self._hits = 0
|
|
90
|
+
self._misses = 0
|
|
91
|
+
|
|
92
|
+
def stats(self) -> dict[str, int]:
|
|
93
|
+
"""Return cache hit/miss statistics."""
|
|
94
|
+
with self._lock:
|
|
95
|
+
return {"hits": self._hits, "misses": self._misses, "size": len(self._cache)}
|
|
96
|
+
|
|
97
|
+
def is_expired(self, key: str) -> bool:
|
|
98
|
+
"""Check if a key exists but is expired (without evicting)."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
entry = self._cache.get(key)
|
|
101
|
+
if entry is None:
|
|
102
|
+
return False
|
|
103
|
+
return time.monotonic() > entry.expires_at
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""CircuitBreaker — 3-state circuit breaker for resolver resilience."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CircuitState(StrEnum):
|
|
11
|
+
CLOSED = "closed"
|
|
12
|
+
OPEN = "open"
|
|
13
|
+
HALF_OPEN = "half_open"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CircuitBreaker:
|
|
17
|
+
"""3-state circuit breaker: CLOSED → OPEN → HALF_OPEN → CLOSED.
|
|
18
|
+
|
|
19
|
+
CLOSED: Normal operation, requests pass through.
|
|
20
|
+
OPEN: Failures exceeded threshold, requests are rejected.
|
|
21
|
+
HALF_OPEN: Probe mode, limited requests allowed to test recovery.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
cb = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0)
|
|
25
|
+
if cb.allow_request():
|
|
26
|
+
try:
|
|
27
|
+
result = do_work()
|
|
28
|
+
cb.record_success()
|
|
29
|
+
except Exception:
|
|
30
|
+
cb.record_failure()
|
|
31
|
+
else:
|
|
32
|
+
result = fallback()
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
failure_threshold: int = 5,
|
|
38
|
+
recovery_timeout: float = 30.0,
|
|
39
|
+
half_open_max_calls: int = 3,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.failure_threshold = failure_threshold
|
|
42
|
+
self.recovery_timeout = recovery_timeout
|
|
43
|
+
self.half_open_max_calls = half_open_max_calls
|
|
44
|
+
self._state = CircuitState.CLOSED
|
|
45
|
+
self._failure_count = 0
|
|
46
|
+
self._success_count = 0
|
|
47
|
+
self._last_failure_time: Optional[float] = None
|
|
48
|
+
self._half_open_calls = 0
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def state(self) -> CircuitState:
|
|
52
|
+
"""Current circuit state, with automatic transition from OPEN → HALF_OPEN."""
|
|
53
|
+
if self._state == CircuitState.OPEN and self._last_failure_time is not None:
|
|
54
|
+
if time.monotonic() - self._last_failure_time >= self.recovery_timeout:
|
|
55
|
+
self._state = CircuitState.HALF_OPEN
|
|
56
|
+
self._half_open_calls = 0
|
|
57
|
+
return self._state
|
|
58
|
+
|
|
59
|
+
def allow_request(self) -> bool:
|
|
60
|
+
"""Check if a request should be allowed through."""
|
|
61
|
+
current = self.state
|
|
62
|
+
if current == CircuitState.CLOSED:
|
|
63
|
+
return True
|
|
64
|
+
if current == CircuitState.HALF_OPEN:
|
|
65
|
+
if self._half_open_calls < self.half_open_max_calls:
|
|
66
|
+
self._half_open_calls += 1
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
return False # OPEN
|
|
70
|
+
|
|
71
|
+
def record_success(self) -> None:
|
|
72
|
+
"""Record a successful request."""
|
|
73
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
74
|
+
self._success_count += 1
|
|
75
|
+
if self._success_count >= self.half_open_max_calls:
|
|
76
|
+
self._reset()
|
|
77
|
+
self._failure_count = 0
|
|
78
|
+
|
|
79
|
+
def record_failure(self) -> None:
|
|
80
|
+
"""Record a failed request."""
|
|
81
|
+
self._failure_count += 1
|
|
82
|
+
self._last_failure_time = time.monotonic()
|
|
83
|
+
self._success_count = 0
|
|
84
|
+
|
|
85
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
86
|
+
self._state = CircuitState.OPEN
|
|
87
|
+
elif self._failure_count >= self.failure_threshold:
|
|
88
|
+
self._state = CircuitState.OPEN
|
|
89
|
+
|
|
90
|
+
def _reset(self) -> None:
|
|
91
|
+
"""Reset to CLOSED state."""
|
|
92
|
+
self._state = CircuitState.CLOSED
|
|
93
|
+
self._failure_count = 0
|
|
94
|
+
self._success_count = 0
|
|
95
|
+
self._half_open_calls = 0
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def failure_count(self) -> int:
|
|
99
|
+
return self._failure_count
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def success_count(self) -> int:
|
|
103
|
+
return self._success_count
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""ConflictDetector — Jaccard similarity conflict detection between skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _tokenize(text: str) -> set[str]:
|
|
10
|
+
"""Tokenize text into lowercase word set."""
|
|
11
|
+
return set(re.findall(r"[a-z0-9_]+", text.lower()))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def jaccard_similarity(set_a: set[str], set_b: set[str]) -> float:
|
|
15
|
+
"""Compute Jaccard similarity between two sets."""
|
|
16
|
+
if not set_a and not set_b:
|
|
17
|
+
return 1.0
|
|
18
|
+
if not set_a or not set_b:
|
|
19
|
+
return 0.0
|
|
20
|
+
intersection = set_a & set_b
|
|
21
|
+
union = set_a | set_b
|
|
22
|
+
return len(intersection) / len(union)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConflictDetector:
|
|
26
|
+
"""Detect naming conflicts between skills using Jaccard similarity.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
cd = ConflictDetector()
|
|
30
|
+
cd.register("S01", "Requirement Coverage", "D1", namespaces=["req", "coverage"])
|
|
31
|
+
conflicts = cd.detect(threshold=0.5)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, threshold: float = 0.5) -> None:
|
|
35
|
+
self.threshold = threshold
|
|
36
|
+
self._skills: dict[str, dict] = {} # skill_id → {name, tokens, namespaces}
|
|
37
|
+
|
|
38
|
+
def register(
|
|
39
|
+
self,
|
|
40
|
+
skill_id: str,
|
|
41
|
+
name: str = "",
|
|
42
|
+
dimension: str = "",
|
|
43
|
+
namespaces: Optional[list[str]] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Register a skill for conflict detection."""
|
|
46
|
+
tokens = _tokenize(f"{name} {dimension} {' '.join(namespaces or [])}")
|
|
47
|
+
self._skills[skill_id] = {
|
|
48
|
+
"name": name,
|
|
49
|
+
"tokens": tokens,
|
|
50
|
+
"namespaces": set(namespaces or []),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def detect(self, threshold: Optional[float] = None) -> list[dict]:
|
|
54
|
+
"""Detect all pairwise conflicts above threshold.
|
|
55
|
+
|
|
56
|
+
Returns list of dicts: {skill_a, skill_b, jaccard_score, severity, overlapping_namespaces}
|
|
57
|
+
"""
|
|
58
|
+
thresh = threshold if threshold is not None else self.threshold
|
|
59
|
+
conflicts = []
|
|
60
|
+
skill_ids = list(self._skills.keys())
|
|
61
|
+
|
|
62
|
+
for i in range(len(skill_ids)):
|
|
63
|
+
for j in range(i + 1, len(skill_ids)):
|
|
64
|
+
a_id, b_id = skill_ids[i], skill_ids[j]
|
|
65
|
+
a_info, b_info = self._skills[a_id], self._skills[b_id]
|
|
66
|
+
|
|
67
|
+
score = jaccard_similarity(a_info["tokens"], b_info["tokens"])
|
|
68
|
+
if score >= thresh:
|
|
69
|
+
overlapping = a_info["namespaces"] & b_info["namespaces"]
|
|
70
|
+
severity = self._classify_severity(score, overlapping)
|
|
71
|
+
conflicts.append(
|
|
72
|
+
{
|
|
73
|
+
"skill_a": a_id,
|
|
74
|
+
"skill_b": b_id,
|
|
75
|
+
"jaccard_score": round(score, 4),
|
|
76
|
+
"severity": severity,
|
|
77
|
+
"conflict_type": self._classify_conflict_type(overlapping, score),
|
|
78
|
+
"overlapping_namespaces": sorted(overlapping),
|
|
79
|
+
"recommendation": self._generate_recommendation(severity, overlapping),
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return conflicts
|
|
84
|
+
|
|
85
|
+
def _classify_severity(self, score: float, overlapping: set[str]) -> str:
|
|
86
|
+
"""Classify conflict severity based on Jaccard score and namespace overlap."""
|
|
87
|
+
if overlapping and score >= 0.7:
|
|
88
|
+
return "high"
|
|
89
|
+
if overlapping or score >= 0.7:
|
|
90
|
+
return "medium"
|
|
91
|
+
return "low"
|
|
92
|
+
|
|
93
|
+
def _classify_conflict_type(self, overlapping: set[str], score: float) -> str:
|
|
94
|
+
"""Classify conflict type per schema enum."""
|
|
95
|
+
if overlapping:
|
|
96
|
+
return "namespace_overlap"
|
|
97
|
+
if score >= 0.8:
|
|
98
|
+
return "semantic_conflict"
|
|
99
|
+
return "namespace_overlap" # Default for Jaccard-based detection
|
|
100
|
+
|
|
101
|
+
def _generate_recommendation(self, severity: str, overlapping: set[str]) -> str:
|
|
102
|
+
"""Generate a recommendation for resolving the conflict."""
|
|
103
|
+
if severity == "high":
|
|
104
|
+
return "Consider merging or removing one of the conflicting skills"
|
|
105
|
+
if severity == "medium":
|
|
106
|
+
return "Review skill boundaries and adjust namespaces"
|
|
107
|
+
return "Monitor for potential future conflicts"
|
|
108
|
+
|
|
109
|
+
def clear(self) -> None:
|
|
110
|
+
"""Remove all registered skills."""
|
|
111
|
+
self._skills.clear()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""HealthFilter — filter skills by health score threshold."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HealthFilter:
|
|
7
|
+
"""Filter skills based on health_score.
|
|
8
|
+
|
|
9
|
+
Skills with health_score < min_score are excluded.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
hf = HealthFilter(min_score=0.6)
|
|
13
|
+
passed, excluded = hf.filter(skills)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, min_score: float = 0.6) -> None:
|
|
17
|
+
self.min_score = min_score
|
|
18
|
+
|
|
19
|
+
def filter(self, skills: list[dict]) -> tuple[list[dict], list[str]]:
|
|
20
|
+
"""Filter skills by health_score.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
skills: List of dicts with at least 'skill_id' and 'health_score' keys.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Tuple of (passed_skills, excluded_skill_ids)
|
|
27
|
+
"""
|
|
28
|
+
passed = []
|
|
29
|
+
excluded = []
|
|
30
|
+
|
|
31
|
+
for skill in skills:
|
|
32
|
+
health = skill.get("health_score", 1.0)
|
|
33
|
+
if health >= self.min_score:
|
|
34
|
+
passed.append(skill)
|
|
35
|
+
else:
|
|
36
|
+
excluded.append(skill.get("skill_id", "unknown"))
|
|
37
|
+
|
|
38
|
+
return passed, excluded
|