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,31 @@
|
|
|
1
|
+
"""Combiner — Skill combination lifecycle management and recommendation.
|
|
2
|
+
|
|
3
|
+
Manages the full lifecycle of skill combinations:
|
|
4
|
+
DISCOVERED → VALIDATING → PROMOTED → DEPRECATED → RETIRED
|
|
5
|
+
↘→ REJECTED
|
|
6
|
+
|
|
7
|
+
Two discovery channels:
|
|
8
|
+
1. Auto-discovered: Thompson Sampling explores new combinations from execution data
|
|
9
|
+
2. Human-specified: Expert annotations in CSDF synergies field
|
|
10
|
+
|
|
11
|
+
Both channels require validation through execution feedback — human specification
|
|
12
|
+
only skips DISCOVERED, entering VALIDATING directly.
|
|
13
|
+
|
|
14
|
+
Part of SkillPool — independent infrastructure, shared by all agents.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from skillpool.combiner.models import (
|
|
18
|
+
CombinationLifecycleState,
|
|
19
|
+
SkillCombination,
|
|
20
|
+
CombinationTransitionResult,
|
|
21
|
+
MIN_VALIDATION_EXECUTIONS,
|
|
22
|
+
)
|
|
23
|
+
from skillpool.combiner.lifecycle import CombinationLifecycleManager
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CombinationLifecycleState",
|
|
27
|
+
"SkillCombination",
|
|
28
|
+
"CombinationTransitionResult",
|
|
29
|
+
"CombinationLifecycleManager",
|
|
30
|
+
"MIN_VALIDATION_EXECUTIONS",
|
|
31
|
+
]
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""CombinationLifecycleManager — Manage skill combination lifecycle transitions.
|
|
2
|
+
|
|
3
|
+
State transitions:
|
|
4
|
+
DISCOVERED → VALIDATING (automatic, after discovery)
|
|
5
|
+
VALIDATING → PROMOTED (gain > 0, confidence > 0.7, exec_count ≥ MIN)
|
|
6
|
+
VALIDATING → REJECTED (gain ≤ 0 or confidence < 0.3)
|
|
7
|
+
PROMOTED → DEPRECATED (30-day avg gain < 50% of historical)
|
|
8
|
+
DEPRECATED → RETIRED (90 days no improvement)
|
|
9
|
+
REJECTED → DISCOVERED (30 days cooldown, can be rediscovered)
|
|
10
|
+
|
|
11
|
+
Human-specified combinations skip DISCOVERED, enter VALIDATING directly.
|
|
12
|
+
|
|
13
|
+
Part of SkillPool — independent infrastructure, shared by all agents.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import fcntl
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import tempfile
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from skillpool.config import get_data_dir
|
|
26
|
+
from skillpool.combiner.models import (
|
|
27
|
+
CombinationLifecycleState,
|
|
28
|
+
SkillCombination,
|
|
29
|
+
CombinationTransitionResult,
|
|
30
|
+
MIN_VALIDATION_EXECUTIONS,
|
|
31
|
+
)
|
|
32
|
+
from skillpool.utils.time_utils import utc_now
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Valid transitions: from_state → list of valid to_states
|
|
38
|
+
_TRANSITIONS: dict[CombinationLifecycleState, list[CombinationLifecycleState]] = {
|
|
39
|
+
CombinationLifecycleState.DISCOVERED: [
|
|
40
|
+
CombinationLifecycleState.VALIDATING,
|
|
41
|
+
CombinationLifecycleState.RETIRED,
|
|
42
|
+
],
|
|
43
|
+
CombinationLifecycleState.VALIDATING: [
|
|
44
|
+
CombinationLifecycleState.PROMOTED,
|
|
45
|
+
CombinationLifecycleState.REJECTED,
|
|
46
|
+
CombinationLifecycleState.RETIRED,
|
|
47
|
+
],
|
|
48
|
+
CombinationLifecycleState.PROMOTED: [
|
|
49
|
+
CombinationLifecycleState.DEPRECATED,
|
|
50
|
+
CombinationLifecycleState.RETIRED,
|
|
51
|
+
],
|
|
52
|
+
CombinationLifecycleState.REJECTED: [
|
|
53
|
+
CombinationLifecycleState.DISCOVERED, # Rediscovery after cooldown
|
|
54
|
+
CombinationLifecycleState.RETIRED,
|
|
55
|
+
],
|
|
56
|
+
CombinationLifecycleState.DEPRECATED: [
|
|
57
|
+
CombinationLifecycleState.PROMOTED, # Re-promoted if gain recovers
|
|
58
|
+
CombinationLifecycleState.RETIRED,
|
|
59
|
+
],
|
|
60
|
+
CombinationLifecycleState.RETIRED: [], # Terminal state
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CombinationLifecycleManager:
|
|
65
|
+
"""Manages skill combination lifecycle states and transitions.
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
manager = CombinationLifecycleManager()
|
|
69
|
+
# Human-specified combination
|
|
70
|
+
combo = manager.create_combination(
|
|
71
|
+
primary="multi-dim-review",
|
|
72
|
+
enhancers=["karpathy-guidelines"],
|
|
73
|
+
source="human_specified",
|
|
74
|
+
)
|
|
75
|
+
# Auto-discovered combination
|
|
76
|
+
combo = manager.create_combination(
|
|
77
|
+
primary="S05a",
|
|
78
|
+
enhancers=["S09"],
|
|
79
|
+
source="auto_discovered",
|
|
80
|
+
)
|
|
81
|
+
# Record execution feedback
|
|
82
|
+
manager.record_execution(combo.combination_id, gain=1.5, success=True)
|
|
83
|
+
# Check if ready for promotion
|
|
84
|
+
result = manager.try_promote(combo.combination_id)
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, data_dir: Path | None = None):
|
|
88
|
+
self.data_dir = data_dir or get_data_dir() / "combinations"
|
|
89
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
self._store_path = self.data_dir / "combinations.jsonl"
|
|
91
|
+
self._combinations: dict[str, SkillCombination] = {}
|
|
92
|
+
self._loaded = False
|
|
93
|
+
|
|
94
|
+
def _ensure_loaded(self) -> None:
|
|
95
|
+
"""Lazy-load combinations from disk."""
|
|
96
|
+
if self._loaded:
|
|
97
|
+
return
|
|
98
|
+
combos_file = self.data_dir / "combinations.jsonl"
|
|
99
|
+
if combos_file.exists():
|
|
100
|
+
for line in combos_file.read_text().splitlines():
|
|
101
|
+
line = line.strip()
|
|
102
|
+
if not line:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
combo = SkillCombination(**json.loads(line))
|
|
106
|
+
self._combinations[combo.combination_id] = combo
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.warning("Failed to parse combination record: %s", e)
|
|
109
|
+
continue
|
|
110
|
+
self._loaded = True
|
|
111
|
+
|
|
112
|
+
def _persist(self, combo: SkillCombination) -> None:
|
|
113
|
+
"""Append a combination to the JSONL file with file lock."""
|
|
114
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
combos_file = self._store_path
|
|
116
|
+
with open(combos_file, "a") as f:
|
|
117
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
118
|
+
f.write(combo.model_dump_json() + "\n")
|
|
119
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
120
|
+
|
|
121
|
+
def _update_persisted(self, combo: SkillCombination) -> None:
|
|
122
|
+
"""Update an existing combination with atomic write and file lock.
|
|
123
|
+
|
|
124
|
+
Uses write-to-temp-then-rename for atomicity, and file lock
|
|
125
|
+
for multi-process safety.
|
|
126
|
+
"""
|
|
127
|
+
self._combinations[combo.combination_id] = combo
|
|
128
|
+
records = [c.model_dump() for c in self._combinations.values()]
|
|
129
|
+
# Atomic write: temp file → rename
|
|
130
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=str(self.data_dir), suffix=".jsonl.tmp")
|
|
131
|
+
try:
|
|
132
|
+
with open(tmp_fd, "w") as f:
|
|
133
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
134
|
+
for record in records:
|
|
135
|
+
f.write(json.dumps(record, default=str) + "\n")
|
|
136
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
137
|
+
Path(tmp_path).rename(self._store_path)
|
|
138
|
+
except Exception:
|
|
139
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
def create_combination(
|
|
143
|
+
self,
|
|
144
|
+
primary: str,
|
|
145
|
+
enhancers: list[str],
|
|
146
|
+
source: str = "auto_discovered",
|
|
147
|
+
base_weight: float = 0.5,
|
|
148
|
+
) -> SkillCombination:
|
|
149
|
+
"""Create a new combination.
|
|
150
|
+
|
|
151
|
+
Human-specified combinations skip DISCOVERED, enter VALIDATING directly.
|
|
152
|
+
Auto-discovered combinations start at DISCOVERED.
|
|
153
|
+
"""
|
|
154
|
+
self._ensure_loaded()
|
|
155
|
+
|
|
156
|
+
# Check if combination already exists
|
|
157
|
+
enhancer_str = "+".join(sorted(enhancers))
|
|
158
|
+
combo_id = f"{primary}+{enhancer_str}"
|
|
159
|
+
|
|
160
|
+
if combo_id in self._combinations:
|
|
161
|
+
existing = self._combinations[combo_id]
|
|
162
|
+
# If retired, allow rediscovery
|
|
163
|
+
if existing.state == CombinationLifecycleState.RETIRED:
|
|
164
|
+
existing.state = CombinationLifecycleState.DISCOVERED
|
|
165
|
+
existing.source = source
|
|
166
|
+
existing.discovered_at = utc_now().isoformat()
|
|
167
|
+
self._update_persisted(existing)
|
|
168
|
+
return existing
|
|
169
|
+
return existing # Already exists in non-retired state
|
|
170
|
+
|
|
171
|
+
initial_state = (
|
|
172
|
+
CombinationLifecycleState.VALIDATING
|
|
173
|
+
if source == "human_specified"
|
|
174
|
+
else CombinationLifecycleState.DISCOVERED
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
combo = SkillCombination(
|
|
178
|
+
primary=primary,
|
|
179
|
+
enhancers=enhancers,
|
|
180
|
+
state=initial_state,
|
|
181
|
+
source=source,
|
|
182
|
+
base_weight=base_weight,
|
|
183
|
+
)
|
|
184
|
+
self._combinations[combo.combination_id] = combo
|
|
185
|
+
self._persist(combo)
|
|
186
|
+
return combo
|
|
187
|
+
|
|
188
|
+
def validate_transition(
|
|
189
|
+
self,
|
|
190
|
+
from_state: CombinationLifecycleState,
|
|
191
|
+
to_state: CombinationLifecycleState,
|
|
192
|
+
) -> bool:
|
|
193
|
+
"""Check if a state transition is valid."""
|
|
194
|
+
if from_state == to_state:
|
|
195
|
+
return False
|
|
196
|
+
return to_state in _TRANSITIONS.get(from_state, [])
|
|
197
|
+
|
|
198
|
+
def transition(
|
|
199
|
+
self,
|
|
200
|
+
combination_id: str,
|
|
201
|
+
to_state: CombinationLifecycleState,
|
|
202
|
+
reason: str = "",
|
|
203
|
+
) -> CombinationTransitionResult:
|
|
204
|
+
"""Execute a lifecycle state transition."""
|
|
205
|
+
self._ensure_loaded()
|
|
206
|
+
|
|
207
|
+
combo = self._combinations.get(combination_id)
|
|
208
|
+
if combo is None:
|
|
209
|
+
return CombinationTransitionResult(
|
|
210
|
+
combination_id=combination_id,
|
|
211
|
+
from_state=CombinationLifecycleState.DISCOVERED,
|
|
212
|
+
to_state=to_state,
|
|
213
|
+
success=False,
|
|
214
|
+
reason="Combination not found",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
from_state = combo.state
|
|
218
|
+
if not self.validate_transition(from_state, to_state):
|
|
219
|
+
return CombinationTransitionResult(
|
|
220
|
+
combination_id=combination_id,
|
|
221
|
+
from_state=from_state,
|
|
222
|
+
to_state=to_state,
|
|
223
|
+
success=False,
|
|
224
|
+
reason=f"Invalid transition: {from_state.name} → {to_state.name}",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
combo.state = to_state
|
|
228
|
+
now = utc_now().isoformat()
|
|
229
|
+
|
|
230
|
+
if to_state == CombinationLifecycleState.PROMOTED:
|
|
231
|
+
combo.promoted_at = now
|
|
232
|
+
elif to_state == CombinationLifecycleState.DEPRECATED:
|
|
233
|
+
combo.deprecated_at = now
|
|
234
|
+
elif to_state == CombinationLifecycleState.REJECTED:
|
|
235
|
+
combo.rejection_reason = reason
|
|
236
|
+
elif to_state == CombinationLifecycleState.DISCOVERED:
|
|
237
|
+
# Rediscovery: reset validation data
|
|
238
|
+
combo.gain_avg = 0.0
|
|
239
|
+
combo.gain_confidence = 0.0
|
|
240
|
+
combo.execution_count = 0
|
|
241
|
+
combo.rejection_reason = ""
|
|
242
|
+
|
|
243
|
+
self._update_persisted(combo)
|
|
244
|
+
|
|
245
|
+
return CombinationTransitionResult(
|
|
246
|
+
combination_id=combination_id,
|
|
247
|
+
from_state=from_state,
|
|
248
|
+
to_state=to_state,
|
|
249
|
+
success=True,
|
|
250
|
+
reason=reason,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def record_execution(
|
|
254
|
+
self,
|
|
255
|
+
combination_id: str,
|
|
256
|
+
gain: float = 0.0,
|
|
257
|
+
success: bool = True,
|
|
258
|
+
) -> SkillCombination | None:
|
|
259
|
+
"""Record an execution outcome for a combination.
|
|
260
|
+
|
|
261
|
+
Updates gain_avg, execution_count, and gain_confidence.
|
|
262
|
+
Automatically transitions DISCOVERED → VALIDATING.
|
|
263
|
+
"""
|
|
264
|
+
self._ensure_loaded()
|
|
265
|
+
|
|
266
|
+
combo = self._combinations.get(combination_id)
|
|
267
|
+
if combo is None:
|
|
268
|
+
logger.debug(f"Combination {combination_id} not found for execution recording")
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
# Auto-transition DISCOVERED → VALIDATING on first execution
|
|
272
|
+
if combo.state == CombinationLifecycleState.DISCOVERED:
|
|
273
|
+
combo.state = CombinationLifecycleState.VALIDATING
|
|
274
|
+
|
|
275
|
+
# Update execution data
|
|
276
|
+
combo.execution_count += 1
|
|
277
|
+
combo.last_execution = utc_now().isoformat()
|
|
278
|
+
|
|
279
|
+
# Update rolling average gain
|
|
280
|
+
old_avg = combo.gain_avg
|
|
281
|
+
n = combo.execution_count
|
|
282
|
+
combo.gain_avg = old_avg + (gain - old_avg) / n
|
|
283
|
+
|
|
284
|
+
# Update recent_gain_avg (approximation: use current gain as proxy)
|
|
285
|
+
# Full implementation would need gain_history with timestamps
|
|
286
|
+
if combo.recent_gain_avg == 0.0:
|
|
287
|
+
combo.recent_gain_avg = gain
|
|
288
|
+
else:
|
|
289
|
+
combo.recent_gain_avg = combo.recent_gain_avg * 0.7 + gain * 0.3
|
|
290
|
+
|
|
291
|
+
# Update confidence: increases with more executions
|
|
292
|
+
combo.gain_confidence = min(1.0, n / MIN_VALIDATION_EXECUTIONS)
|
|
293
|
+
|
|
294
|
+
self._update_persisted(combo)
|
|
295
|
+
return combo
|
|
296
|
+
|
|
297
|
+
def try_promote(self, combination_id: str) -> CombinationTransitionResult:
|
|
298
|
+
"""Try to promote a VALIDATING combination to PROMOTED.
|
|
299
|
+
|
|
300
|
+
Conditions:
|
|
301
|
+
- state == VALIDATING
|
|
302
|
+
- execution_count >= MIN_VALIDATION_EXECUTIONS
|
|
303
|
+
- gain_avg > 0
|
|
304
|
+
- gain_confidence >= 0.7
|
|
305
|
+
"""
|
|
306
|
+
self._ensure_loaded()
|
|
307
|
+
|
|
308
|
+
combo = self._combinations.get(combination_id)
|
|
309
|
+
if combo is None:
|
|
310
|
+
return CombinationTransitionResult(
|
|
311
|
+
combination_id=combination_id,
|
|
312
|
+
from_state=CombinationLifecycleState.VALIDATING,
|
|
313
|
+
to_state=CombinationLifecycleState.PROMOTED,
|
|
314
|
+
success=False,
|
|
315
|
+
reason="Combination not found",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if combo.state != CombinationLifecycleState.VALIDATING:
|
|
319
|
+
return CombinationTransitionResult(
|
|
320
|
+
combination_id=combination_id,
|
|
321
|
+
from_state=combo.state,
|
|
322
|
+
to_state=CombinationLifecycleState.PROMOTED,
|
|
323
|
+
success=False,
|
|
324
|
+
reason=f"State is {combo.state.name}, not VALIDATING",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if combo.execution_count < MIN_VALIDATION_EXECUTIONS:
|
|
328
|
+
return CombinationTransitionResult(
|
|
329
|
+
combination_id=combination_id,
|
|
330
|
+
from_state=CombinationLifecycleState.VALIDATING,
|
|
331
|
+
to_state=CombinationLifecycleState.PROMOTED,
|
|
332
|
+
success=False,
|
|
333
|
+
reason=f"Only {combo.execution_count} executions, need {MIN_VALIDATION_EXECUTIONS}",
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if combo.gain_avg <= 0:
|
|
337
|
+
# Reject instead
|
|
338
|
+
return self.transition(
|
|
339
|
+
combination_id,
|
|
340
|
+
CombinationLifecycleState.REJECTED,
|
|
341
|
+
reason=f"Negative gain: {combo.gain_avg:.2f}",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if combo.gain_confidence < 0.7:
|
|
345
|
+
return CombinationTransitionResult(
|
|
346
|
+
combination_id=combination_id,
|
|
347
|
+
from_state=CombinationLifecycleState.VALIDATING,
|
|
348
|
+
to_state=CombinationLifecycleState.PROMOTED,
|
|
349
|
+
success=False,
|
|
350
|
+
reason=f"Confidence too low: {combo.gain_confidence:.2f}, need 0.7",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Snapshot all-time gain average at promotion time (for future deprecation checks)
|
|
354
|
+
combo.all_time_gain_avg = combo.gain_avg
|
|
355
|
+
combo.recent_gain_avg = combo.gain_avg
|
|
356
|
+
|
|
357
|
+
return self.transition(
|
|
358
|
+
combination_id,
|
|
359
|
+
CombinationLifecycleState.PROMOTED,
|
|
360
|
+
reason=f"Gain={combo.gain_avg:.2f}, Confidence={combo.gain_confidence:.2f}",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def check_deprecation(self, combination_id: str) -> CombinationTransitionResult | None:
|
|
364
|
+
"""Check if a PROMOTED combination should be deprecated.
|
|
365
|
+
|
|
366
|
+
Triggers:
|
|
367
|
+
1. No execution in 30 days
|
|
368
|
+
2. Recent (30-day) average gain < 50% of all-time average gain
|
|
369
|
+
"""
|
|
370
|
+
self._ensure_loaded()
|
|
371
|
+
|
|
372
|
+
combo = self._combinations.get(combination_id)
|
|
373
|
+
if combo is None or combo.state != CombinationLifecycleState.PROMOTED:
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
now = utc_now()
|
|
377
|
+
|
|
378
|
+
# Check 1: No execution in 30 days
|
|
379
|
+
if combo.last_execution:
|
|
380
|
+
try:
|
|
381
|
+
last = datetime.fromisoformat(combo.last_execution)
|
|
382
|
+
days_inactive = (now - last).days
|
|
383
|
+
if days_inactive > 30:
|
|
384
|
+
return self.transition(
|
|
385
|
+
combination_id,
|
|
386
|
+
CombinationLifecycleState.DEPRECATED,
|
|
387
|
+
reason=f"No execution in {days_inactive} days",
|
|
388
|
+
)
|
|
389
|
+
except (ValueError, TypeError):
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
# Check 2: Recent gain < 50% of all-time gain
|
|
393
|
+
if combo.all_time_gain_avg > 0 and combo.recent_gain_avg > 0:
|
|
394
|
+
gain_ratio = combo.recent_gain_avg / combo.all_time_gain_avg
|
|
395
|
+
if gain_ratio < 0.5:
|
|
396
|
+
return self.transition(
|
|
397
|
+
combination_id,
|
|
398
|
+
CombinationLifecycleState.DEPRECATED,
|
|
399
|
+
reason=f"Recent gain ({combo.recent_gain_avg:.2f}) "
|
|
400
|
+
f"< 50% of all-time ({combo.all_time_gain_avg:.2f}) "
|
|
401
|
+
f"(ratio={gain_ratio:.2f})",
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
def check_retirement(self, combination_id: str) -> CombinationTransitionResult | None:
|
|
407
|
+
"""Check if a DEPRECATED combination should be retired.
|
|
408
|
+
|
|
409
|
+
Condition: 90 days since deprecation with no improvement.
|
|
410
|
+
"""
|
|
411
|
+
self._ensure_loaded()
|
|
412
|
+
|
|
413
|
+
combo = self._combinations.get(combination_id)
|
|
414
|
+
if combo is None or combo.state != CombinationLifecycleState.DEPRECATED:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
if combo.deprecated_at:
|
|
418
|
+
try:
|
|
419
|
+
deprecated = datetime.fromisoformat(combo.deprecated_at)
|
|
420
|
+
days_deprecated = (utc_now() - deprecated).days
|
|
421
|
+
if days_deprecated > 90:
|
|
422
|
+
return self.transition(
|
|
423
|
+
combination_id,
|
|
424
|
+
CombinationLifecycleState.RETIRED,
|
|
425
|
+
reason=f"Deprecated for {days_deprecated} days with no improvement",
|
|
426
|
+
)
|
|
427
|
+
except (ValueError, TypeError):
|
|
428
|
+
pass
|
|
429
|
+
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
def get_combination(self, combination_id: str) -> SkillCombination | None:
|
|
433
|
+
"""Get a combination by ID. Returns None if not found."""
|
|
434
|
+
self._ensure_loaded()
|
|
435
|
+
return self._combinations.get(combination_id)
|
|
436
|
+
|
|
437
|
+
def get_promoted_combinations(self, primary: str = "") -> list[SkillCombination]:
|
|
438
|
+
"""Get all PROMOTED combinations, optionally filtered by primary skill."""
|
|
439
|
+
self._ensure_loaded()
|
|
440
|
+
results = [c for c in self._combinations.values() if c.state == CombinationLifecycleState.PROMOTED]
|
|
441
|
+
if primary:
|
|
442
|
+
results = [c for c in results if c.primary == primary]
|
|
443
|
+
return sorted(results, key=lambda c: c.current_weight(), reverse=True)
|
|
444
|
+
|
|
445
|
+
def get_validating_combinations(self) -> list[SkillCombination]:
|
|
446
|
+
"""Get all combinations currently in VALIDATING state."""
|
|
447
|
+
self._ensure_loaded()
|
|
448
|
+
return [c for c in self._combinations.values() if c.state == CombinationLifecycleState.VALIDATING]
|
|
449
|
+
|
|
450
|
+
def get_combinations_for_skill(self, skill_id: str) -> list[SkillCombination]:
|
|
451
|
+
"""Get all combinations involving a skill (as primary or enhancer)."""
|
|
452
|
+
self._ensure_loaded()
|
|
453
|
+
return [c for c in self._combinations.values() if c.primary == skill_id or skill_id in c.enhancers]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Combination data models — SkillCombination + lifecycle state enum."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from skillpool.utils.time_utils import utc_now
|
|
10
|
+
|
|
11
|
+
# Minimum executions before a combination can be promoted
|
|
12
|
+
MIN_VALIDATION_EXECUTIONS = 5
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CombinationLifecycleState(IntEnum):
|
|
16
|
+
"""Lifecycle states for skill combinations."""
|
|
17
|
+
|
|
18
|
+
DISCOVERED = 0 # New combination, awaiting validation
|
|
19
|
+
VALIDATING = 1 # Collecting execution data
|
|
20
|
+
PROMOTED = 2 # Validated, positive gain, officially recommended
|
|
21
|
+
REJECTED = 3 # Validation failed, negative or insufficient gain
|
|
22
|
+
DEPRECATED = 4 # Gain decayed, no longer recommended
|
|
23
|
+
RETIRED = 5 # Permanently removed
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SkillCombination(BaseModel):
|
|
27
|
+
"""A skill combination with lifecycle state and gain data."""
|
|
28
|
+
|
|
29
|
+
combination_id: str = Field(default="", description="Unique combination ID")
|
|
30
|
+
primary: str = Field(description="Primary skill ID")
|
|
31
|
+
enhancers: list[str] = Field(default_factory=list, description="Enhancing skill IDs")
|
|
32
|
+
state: CombinationLifecycleState = Field(
|
|
33
|
+
default=CombinationLifecycleState.DISCOVERED,
|
|
34
|
+
description="Current lifecycle state",
|
|
35
|
+
)
|
|
36
|
+
source: str = Field(
|
|
37
|
+
default="auto_discovered",
|
|
38
|
+
description="Discovery source: auto_discovered | human_specified",
|
|
39
|
+
)
|
|
40
|
+
gain_avg: float = Field(default=0.0, description="Average gain across executions")
|
|
41
|
+
gain_confidence: float = Field(
|
|
42
|
+
default=0.0,
|
|
43
|
+
ge=0.0,
|
|
44
|
+
le=1.0,
|
|
45
|
+
description="Confidence in gain estimate [0,1]",
|
|
46
|
+
)
|
|
47
|
+
execution_count: int = Field(default=0, description="Total executions")
|
|
48
|
+
last_execution: str = Field(default="", description="ISO 8601 timestamp")
|
|
49
|
+
discovered_at: str = Field(default="", description="When discovered")
|
|
50
|
+
promoted_at: str = Field(default="", description="When promoted")
|
|
51
|
+
deprecated_at: str = Field(default="", description="When deprecated")
|
|
52
|
+
all_time_gain_avg: float = Field(default=0.0, description="All-time average gain (computed on promotion)")
|
|
53
|
+
recent_gain_avg: float = Field(default=0.0, description="Recent average gain (computed on deprecation check)")
|
|
54
|
+
base_weight: float = Field(default=0.5, ge=0.0, le=1.0, description="Base weight")
|
|
55
|
+
decay_lambda: float = Field(default=0.01, description="Time decay coefficient")
|
|
56
|
+
rejection_reason: str = Field(default="", description="Why rejected, if applicable")
|
|
57
|
+
|
|
58
|
+
def model_post_init(self, __context: object) -> None:
|
|
59
|
+
if not self.combination_id:
|
|
60
|
+
enhancer_str = "+".join(sorted(self.enhancers))
|
|
61
|
+
self.combination_id = f"{self.primary}+{enhancer_str}"
|
|
62
|
+
if not self.discovered_at:
|
|
63
|
+
self.discovered_at = utc_now().isoformat()
|
|
64
|
+
|
|
65
|
+
def current_weight(self) -> float:
|
|
66
|
+
"""Compute dynamic weight: base × time_decay × confidence."""
|
|
67
|
+
if self.state not in (
|
|
68
|
+
CombinationLifecycleState.PROMOTED,
|
|
69
|
+
CombinationLifecycleState.VALIDATING,
|
|
70
|
+
):
|
|
71
|
+
return 0.0
|
|
72
|
+
|
|
73
|
+
# Time decay
|
|
74
|
+
if self.last_execution:
|
|
75
|
+
from datetime import datetime
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
last = datetime.fromisoformat(self.last_execution)
|
|
79
|
+
days = (utc_now() - last).days
|
|
80
|
+
time_decay = max(0.1, 1.0 - self.decay_lambda * days)
|
|
81
|
+
except (ValueError, TypeError):
|
|
82
|
+
time_decay = 1.0
|
|
83
|
+
else:
|
|
84
|
+
time_decay = 0.5
|
|
85
|
+
|
|
86
|
+
# Confidence factor
|
|
87
|
+
confidence_factor = min(1.0, self.execution_count / MIN_VALIDATION_EXECUTIONS)
|
|
88
|
+
|
|
89
|
+
return self.base_weight * time_decay * confidence_factor
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CombinationTransitionResult(BaseModel):
|
|
93
|
+
"""Result of a lifecycle state transition."""
|
|
94
|
+
|
|
95
|
+
combination_id: str
|
|
96
|
+
from_state: CombinationLifecycleState
|
|
97
|
+
to_state: CombinationLifecycleState
|
|
98
|
+
success: bool = True
|
|
99
|
+
reason: str = ""
|
skillpool/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""SkillPool configuration — single source of truth for all paths and settings.
|
|
2
|
+
|
|
3
|
+
Environment variables:
|
|
4
|
+
SKILLPOOL_DATA_DIR — Base data directory (default: ~/.skillpool)
|
|
5
|
+
SKILLPOOL_LOG_LEVEL — Log level (default: INFO)
|
|
6
|
+
SKILLPOOL_HOST — Server host (default: 127.0.0.1)
|
|
7
|
+
SKILLPOOL_PORT — Server port (default: 8101)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_data_dir() -> Path:
|
|
17
|
+
"""Return the SkillPool data directory, respecting SKILLPOOL_DATA_DIR env var."""
|
|
18
|
+
env = os.environ.get("SKILLPOOL_DATA_DIR")
|
|
19
|
+
return Path(env) if env else Path.home() / ".skillpool"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_log_level() -> str:
|
|
23
|
+
"""Return the log level, respecting SKILLPOOL_LOG_LEVEL env var."""
|
|
24
|
+
return os.environ.get("SKILLPOOL_LOG_LEVEL", "INFO").upper()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_host() -> str:
|
|
28
|
+
"""Return the server host, respecting SKILLPOOL_HOST env var."""
|
|
29
|
+
return os.environ.get("SKILLPOOL_HOST", "127.0.0.1")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_port() -> int:
|
|
33
|
+
"""Return the server port, respecting SKILLPOOL_PORT env var."""
|
|
34
|
+
return int(os.environ.get("SKILLPOOL_PORT", "8101"))
|