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.
Files changed (90) hide show
  1. skillpool/__init__.py +74 -0
  2. skillpool/__main__.py +6 -0
  3. skillpool/adapters/__init__.py +8 -0
  4. skillpool/adapters/base.py +41 -0
  5. skillpool/adapters/claude_adapter.py +36 -0
  6. skillpool/adapters/codex_adapter.py +92 -0
  7. skillpool/adapters/hermes_adapter.py +38 -0
  8. skillpool/audit/__init__.py +651 -0
  9. skillpool/bridge/__init__.py +16 -0
  10. skillpool/bridge/freeze_detector.py +134 -0
  11. skillpool/bridge/maintenance.py +119 -0
  12. skillpool/bridge/wal_manager.py +136 -0
  13. skillpool/clawmem_client.py +176 -0
  14. skillpool/cli.py +700 -0
  15. skillpool/combiner/__init__.py +31 -0
  16. skillpool/combiner/lifecycle.py +453 -0
  17. skillpool/combiner/models.py +99 -0
  18. skillpool/config.py +34 -0
  19. skillpool/cost/__init__.py +111 -0
  20. skillpool/cost/audit_hash.py +51 -0
  21. skillpool/cost/budget_tracker.py +66 -0
  22. skillpool/cost/dashboard.py +189 -0
  23. skillpool/cost/models.py +129 -0
  24. skillpool/cost/token_governor.py +264 -0
  25. skillpool/cost/trace_ceiling.py +38 -0
  26. skillpool/csdf.py +126 -0
  27. skillpool/evolver/__init__.py +978 -0
  28. skillpool/gain/__init__.py +285 -0
  29. skillpool/gate.py +282 -0
  30. skillpool/gate_policy/__init__.py +31 -0
  31. skillpool/gate_policy/incremental.py +157 -0
  32. skillpool/gate_policy/parser.py +258 -0
  33. skillpool/gate_policy/state_machine.py +432 -0
  34. skillpool/graph/__init__.py +14 -0
  35. skillpool/graph/ppr.py +279 -0
  36. skillpool/health/__init__.py +73 -0
  37. skillpool/health/check.py +85 -0
  38. skillpool/health/degradation.py +90 -0
  39. skillpool/health/models.py +43 -0
  40. skillpool/hooks/__init__.py +4 -0
  41. skillpool/hooks/security_scanner.py +288 -0
  42. skillpool/lifecycle.py +150 -0
  43. skillpool/materializer/__init__.py +124 -0
  44. skillpool/materializer/budget_cropper.py +178 -0
  45. skillpool/materializer/csdf_loader.py +114 -0
  46. skillpool/materializer/lazy_loader.py +265 -0
  47. skillpool/materializer/lifecycle_filter.py +93 -0
  48. skillpool/materializer/mapper.py +178 -0
  49. skillpool/materializer/models.py +66 -0
  50. skillpool/mcp_server.py +2005 -0
  51. skillpool/monitor/__init__.py +576 -0
  52. skillpool/monitor/bug_collector.py +392 -0
  53. skillpool/monitor/defect_classifier.py +218 -0
  54. skillpool/monitor/self_healing.py +530 -0
  55. skillpool/monitor/telemetry_bridge.py +197 -0
  56. skillpool/paradigm/__init__.py +312 -0
  57. skillpool/paradigm/override.py +285 -0
  58. skillpool/profile.py +94 -0
  59. skillpool/quality.py +254 -0
  60. skillpool/registry/__init__.py +509 -0
  61. skillpool/registry/models.py +98 -0
  62. skillpool/resolver/__init__.py +320 -0
  63. skillpool/resolver/cache.py +103 -0
  64. skillpool/resolver/circuit_breaker.py +103 -0
  65. skillpool/resolver/conflict_detector.py +111 -0
  66. skillpool/resolver/health_filter.py +38 -0
  67. skillpool/resolver/models.py +154 -0
  68. skillpool/resolver/rate_limiter.py +48 -0
  69. skillpool/resolver/skill_graph.py +183 -0
  70. skillpool/review/__init__.py +242 -0
  71. skillpool/review/async_queue.py +96 -0
  72. skillpool/review/checkpoint_runner.py +345 -0
  73. skillpool/review/models.py +164 -0
  74. skillpool/review/suspect_marker.py +39 -0
  75. skillpool/review/veto_evaluator.py +94 -0
  76. skillpool/router/__init__.py +481 -0
  77. skillpool/schemas.py +119 -0
  78. skillpool/synergy/__init__.py +240 -0
  79. skillpool/synergy/detector.py +5 -0
  80. skillpool/telemetry.py +126 -0
  81. skillpool/utils/__init__.py +21 -0
  82. skillpool/utils/changelog.py +218 -0
  83. skillpool/utils/logger.py +273 -0
  84. skillpool/utils/runtime_audit.py +163 -0
  85. skillpool/utils/time_utils.py +13 -0
  86. skillpool-4.3.0.dist-info/METADATA +21 -0
  87. skillpool-4.3.0.dist-info/RECORD +90 -0
  88. skillpool-4.3.0.dist-info/WHEEL +5 -0
  89. skillpool-4.3.0.dist-info/entry_points.txt +3 -0
  90. 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"))