diff --git a/skillforge/advanced/__init__.py b/skillforge/advanced/__init__.py index 2cf7ed1..f2c9b31 100644 --- a/skillforge/advanced/__init__.py +++ b/skillforge/advanced/__init__.py @@ -1,13 +1,43 @@ -"""SkillForge Advanced — RL optimization, multi-agent, prediction, and transfer.""" +"""SkillForge Advanced — RL optimization, multi-agent, prediction, transfer, elastic memory, and A/B testing.""" -from skillforge.advanced.rl_optimizer import RLOptimizer, TrainingStep +from skillforge.advanced.rl_optimizer import ( + RLOptimizer, + TrainingStep, + Experience, + ReplayBuffer, + ContextualBandit, + RewardModel, + CurriculumScheduler, +) from skillforge.advanced.multi_agent import SharedSkillPool, AgentAccess, AccessLevel from skillforge.advanced.predictor import SkillPredictor, SkillPrediction from skillforge.advanced.transfer import SkillTransferEngine, TransferResult +from skillforge.advanced.skill_generator import ( + SkillGenerator, + GeneratedSkill, + GenerationRequest, +) +from skillforge.advanced.elastic_memory import ElasticMemory, MemoryEntry +from skillforge.advanced.ab_testing import ( + ABTestRunner, + ExperimentConfig, + ExperimentResult, + ExperimentStatus, + Variant, + Outcome, + VariantStats, + MetricType, + AssignmentStrategy, +) __all__ = [ "RLOptimizer", "TrainingStep", + "Experience", + "ReplayBuffer", + "ContextualBandit", + "RewardModel", + "CurriculumScheduler", "SharedSkillPool", "AgentAccess", "AccessLevel", @@ -15,4 +45,18 @@ "SkillPrediction", "SkillTransferEngine", "TransferResult", + "SkillGenerator", + "GeneratedSkill", + "GenerationRequest", + "ElasticMemory", + "MemoryEntry", + "ABTestRunner", + "ExperimentConfig", + "ExperimentResult", + "ExperimentStatus", + "Variant", + "Outcome", + "VariantStats", + "MetricType", + "AssignmentStrategy", ] diff --git a/skillforge/advanced/ab_testing.py b/skillforge/advanced/ab_testing.py new file mode 100644 index 0000000..b615f0b --- /dev/null +++ b/skillforge/advanced/ab_testing.py @@ -0,0 +1,923 @@ +"""A/B Testing framework for comparing skill variants. + +Enables controlled experiments that route traffic between two or more skill +variants, record outcomes, and apply statistical tests to determine whether +one variant significantly outperforms the others. + +Key concepts: + +- **Experiment**: a named test with a set of variants and a target metric. +- **Variant**: a skill (or skill version) participating in the experiment. +- **Assignment**: deterministic or random traffic routing to a variant. +- **Outcome**: a recorded success/failure for a single invocation. +- **Result**: aggregated metrics and statistical significance. + +Statistical tests are implemented in pure stdlib (no scipy) and cover +the two most common cases: two-proportion z-test and chi-squared +goodness-of-fit for multi-variant experiments. + +No external dependencies — stdlib only. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import math +import random +import sqlite3 +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Enumerations +# --------------------------------------------------------------------------- + + +class ExperimentStatus(Enum): + """Lifecycle status of an A/B experiment.""" + + CREATED = "created" + RUNNING = "running" + PAUSED = "paused" + COMPLETED = "completed" + ABANDONED = "abandoned" + + +class MetricType(Enum): + """Supported metric types for experiment evaluation.""" + + SUCCESS_RATE = "success_rate" + LATENCY = "latency" + TOKEN_EFFICIENCY = "token_efficiency" + USER_RATING = "user_rating" + + +class AssignmentStrategy(Enum): + """Traffic assignment strategies.""" + + RANDOM = "random" + ROUND_ROBIN = "round_robin" + WEIGHTED_RANDOM = "weighted_random" + HASH_BASED = "hash_based" + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class Variant: + """A skill variant participating in an A/B experiment. + + Attributes + ---------- + skill_id : str + The SkillForge skill ID this variant routes to. + weight : float + Relative traffic weight (used by WEIGHTED_RANDOM). Must be > 0. + description : str + Optional human-readable label for this variant. + """ + + skill_id: str + weight: float = 1.0 + description: str = "" + + def __post_init__(self) -> None: + if self.weight <= 0: + raise ValueError(f"Variant weight must be > 0, got {self.weight}") + + +@dataclass +class ExperimentConfig: + """Configuration for an A/B experiment. + + Attributes + ---------- + name : str + Human-readable experiment name. + variants : list[Variant] + Two or more variants to compare. + metric : MetricType + The primary metric to evaluate. + assignment_strategy : AssignmentStrategy + How incoming requests are routed to variants. + alpha : float + Significance level for statistical tests (default 0.05). + min_sample_size : int + Minimum total outcomes before evaluation is attempted. + max_duration_hours : int | None + Optional auto-stop after this many hours. + """ + + name: str + variants: list[Variant] + metric: MetricType = MetricType.SUCCESS_RATE + assignment_strategy: AssignmentStrategy = AssignmentStrategy.RANDOM + alpha: float = 0.05 + min_sample_size: int = 30 + max_duration_hours: int | None = None + + def __post_init__(self) -> None: + if len(self.variants) < 2: + raise ValueError( + f"An experiment requires at least 2 variants, got {len(self.variants)}" + ) + if not 0 < self.alpha < 1: + raise ValueError(f"Alpha must be in (0, 1), got {self.alpha}") + if self.min_sample_size < 2: + raise ValueError( + f"min_sample_size must be >= 2, got {self.min_sample_size}" + ) + + +@dataclass +class Outcome: + """A single recorded outcome for an experiment invocation. + + Attributes + ---------- + id : str + Unique outcome identifier. + experiment_id : str + The experiment this outcome belongs to. + variant_index : int + Index of the variant that was assigned. + success : bool + Whether the invocation was considered a success. + metric_value : float + The observed metric value (e.g. latency in ms, rating 0-1). + context : dict[str, Any] + Free-form context (user_id, input_hash, etc.). + created_at : str + ISO-8601 UTC timestamp. + """ + + id: str + experiment_id: str + variant_index: int + success: bool + metric_value: float = 1.0 + context: dict[str, Any] = field(default_factory=dict) + created_at: str = "" + + def __post_init__(self) -> None: + if not self.created_at: + self.created_at = datetime.now(timezone.utc).isoformat() + + +@dataclass +class VariantStats: + """Aggregated statistics for a single variant. + + Attributes + ---------- + variant_index : int + Position in the experiment's variant list. + skill_id : str + The skill backing this variant. + sample_size : int + Total number of outcomes recorded. + successes : int + Number of successful outcomes. + success_rate : float + successes / sample_size (0.0 if no samples). + mean_metric : float + Average metric_value across all outcomes. + variance_metric : float + Variance of metric_value across outcomes. + """ + + variant_index: int + skill_id: str + sample_size: int = 0 + successes: int = 0 + success_rate: float = 0.0 + mean_metric: float = 0.0 + variance_metric: float = 0.0 + + +@dataclass +class ExperimentResult: + """Result of evaluating a completed (or in-progress) experiment. + + Attributes + ---------- + experiment_id : str + The experiment identifier. + status : ExperimentStatus + Current experiment status. + variant_stats : list[VariantStats] + Per-variant aggregated statistics. + best_variant_index : int | None + Index of the winning variant, or ``None`` if inconclusive. + is_significant : bool + Whether the statistical test reached the configured alpha. + p_value : float | None + The computed p-value, or ``None`` if insufficient data. + test_name : str + Name of the statistical test applied. + summary : str + Human-readable summary of the result. + evaluated_at : str + ISO-8601 UTC timestamp of evaluation. + """ + + experiment_id: str + status: ExperimentStatus + variant_stats: list[VariantStats] = field(default_factory=list) + best_variant_index: int | None = None + is_significant: bool = False + p_value: float | None = None + test_name: str = "" + summary: str = "" + evaluated_at: str = "" + + def __post_init__(self) -> None: + if not self.evaluated_at: + self.evaluated_at = datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# Statistical helpers (stdlib-only) +# --------------------------------------------------------------------------- + + +def _normal_cdf(x: float) -> float: + """Approximate the standard normal CDF using Abramowitz & Stegun 7.1.26. + + Accurate to ~1.5e-7 for all x. + """ + if x < -8.0: + return 0.0 + if x > 8.0: + return 1.0 + sign = 1.0 if x >= 0 else -1.0 + x = abs(x) / math.sqrt(2.0) + t = 1.0 / (1.0 + 0.3275911 * x) + poly = t * ( + 0.254829592 + + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))) + ) + erf_approx = 1.0 - poly * math.exp(-(x * x)) + return 0.5 * (1.0 + sign * erf_approx) + + +def _two_proportion_z_test( + n1: int, s1: int, n2: int, s2: int +) -> tuple[float, float]: + """Two-proportion z-test returning (z_statistic, two_tailed_p_value). + + Parameters + ---------- + n1, s1 : int + Sample size and successes for group 1. + n2, s2 : int + Sample size and successes for group 2. + + Returns + ------- + tuple[float, float] + ``(z, p)`` where *z* is the z-statistic and *p* the two-tailed + p-value. Returns ``(0.0, 1.0)`` when the test is undefined + (e.g. zero variance). + """ + if n1 == 0 or n2 == 0: + return 0.0, 1.0 + p1 = s1 / n1 + p2 = s2 / n2 + p_pool = (s1 + s2) / (n1 + n2) + variance = p_pool * (1.0 - p_pool) * (1.0 / n1 + 1.0 / n2) + if variance <= 0: + return 0.0, 1.0 + z = (p1 - p2) / math.sqrt(variance) + p = 2.0 * (1.0 - _normal_cdf(abs(z))) + return z, p + + +def _chi_squared_test(observed: list[int], expected: list[float]) -> tuple[float, float]: + """Chi-squared goodness-of-fit test. + + Parameters + ---------- + observed : list[int] + Observed counts per category. + expected : list[float] + Expected counts per category (must be > 0). + + Returns + ------- + tuple[float, float] + ``(chi2, p_value)``. Returns ``(0.0, 1.0)`` for degenerate inputs. + """ + if len(observed) != len(expected) or len(observed) < 2: + return 0.0, 1.0 + if any(e <= 0 for e in expected): + return 0.0, 1.0 + + chi2 = sum( + (o - e) ** 2 / e for o, e in zip(observed, expected) + ) + df = len(observed) - 1 + + # Approximate p-value from chi-squared using Wilson-Hilferty normal + # approximation: Z ≈ ((X/k)^(1/3) - 1 + 2/(9k)) / sqrt(2/(9k)) + if df <= 0: + return chi2, 1.0 + ratio = (chi2 / df) ** (1.0 / 3.0) + correction = 2.0 / (9.0 * df) + z_wh = (ratio - 1.0 + correction) / math.sqrt(correction) + p = 1.0 - _normal_cdf(z_wh) + return chi2, max(0.0, min(1.0, p)) + + +# --------------------------------------------------------------------------- +# ABTestRunner +# --------------------------------------------------------------------------- + + +class ABTestRunner: + """Orchestrates A/B experiments: creation, assignment, recording, and + evaluation. + + All state is persisted in a SQLite database so experiments survive + process restarts. + + Parameters + ---------- + db_path : str | Path | None + Path to the SQLite database. Defaults to + ``~/.skillforge/ab_testing.db``. + seed : int | None + Optional random seed for reproducible assignment in tests. + """ + + def __init__( + self, + db_path: str | Path | None = None, + seed: int | None = None, + ) -> None: + self._db_path = str(db_path or Path.home() / ".skillforge" / "ab_testing.db") + Path(self._db_path).parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(self._db_path) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._ensure_tables() + + self._rng = random.Random(seed) + self._round_robin_counters: dict[str, int] = {} + + # ------------------------------------------------------------------ + # Schema + # ------------------------------------------------------------------ + + def _ensure_tables(self) -> None: + """Create tables if they do not already exist.""" + self._conn.executescript( + """ + CREATE TABLE IF NOT EXISTS experiments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'created', + config_json TEXT NOT NULL, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT + ); + CREATE TABLE IF NOT EXISTS outcomes ( + id TEXT PRIMARY KEY, + experiment_id TEXT NOT NULL, + variant_index INTEGER NOT NULL, + success INTEGER NOT NULL, + metric_value REAL NOT NULL DEFAULT 1.0, + context_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY (experiment_id) REFERENCES experiments(id) + ); + CREATE INDEX IF NOT EXISTS idx_outcomes_experiment + ON outcomes(experiment_id); + CREATE INDEX IF NOT EXISTS idx_outcomes_variant + ON outcomes(experiment_id, variant_index); + """ + ) + self._conn.commit() + + # ------------------------------------------------------------------ + # Experiment lifecycle + # ------------------------------------------------------------------ + + def create_experiment(self, config: ExperimentConfig) -> str: + """Create a new experiment and return its ID. + + Parameters + ---------- + config : ExperimentConfig + Full experiment configuration. + + Returns + ------- + str + The unique experiment ID. + + Raises + ------ + ValueError + If an experiment with the same name already exists and is + still running. + """ + experiment_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + config_dict: dict[str, Any] = { + "name": config.name, + "variants": [ + { + "skill_id": v.skill_id, + "weight": v.weight, + "description": v.description, + } + for v in config.variants + ], + "metric": config.metric.value, + "assignment_strategy": config.assignment_strategy.value, + "alpha": config.alpha, + "min_sample_size": config.min_sample_size, + "max_duration_hours": config.max_duration_hours, + } + try: + self._conn.execute( + "INSERT INTO experiments (id, name, status, config_json, created_at) " + "VALUES (:id, :name, :status, :config, :now)", + { + "id": experiment_id, + "name": config.name, + "status": ExperimentStatus.CREATED.value, + "config": json.dumps(config_dict), + "now": now, + }, + ) + self._conn.commit() + except sqlite3.IntegrityError as exc: + raise ValueError(f"Experiment '{config.name}' conflict") from exc + + logger.info( + "Created experiment '%s' (%s) with %d variants", + config.name, + experiment_id, + len(config.variants), + ) + return experiment_id + + def start_experiment(self, experiment_id: str) -> None: + """Transition an experiment to RUNNING status.""" + cur = self._conn.execute( + "UPDATE experiments SET status = :s, started_at = :now " + "WHERE id = :id AND status = 'created'", + { + "s": ExperimentStatus.RUNNING.value, + "now": datetime.now(timezone.utc).isoformat(), + "id": experiment_id, + }, + ) + self._conn.commit() + if cur.rowcount == 0: + raise ValueError( + f"Experiment '{experiment_id}' not found or not in 'created' status" + ) + logger.info("Started experiment %s", experiment_id) + + def pause_experiment(self, experiment_id: str) -> None: + """Transition a running experiment to PAUSED.""" + cur = self._conn.execute( + "UPDATE experiments SET status = :s WHERE id = :id AND status = 'running'", + {"s": ExperimentStatus.PAUSED.value, "id": experiment_id}, + ) + self._conn.commit() + if cur.rowcount == 0: + raise ValueError( + f"Experiment '{experiment_id}' not found or not in 'running' status" + ) + + def resume_experiment(self, experiment_id: str) -> None: + """Transition a paused experiment back to RUNNING.""" + cur = self._conn.execute( + "UPDATE experiments SET status = :s WHERE id = :id AND status = 'paused'", + {"s": ExperimentStatus.RUNNING.value, "id": experiment_id}, + ) + self._conn.commit() + if cur.rowcount == 0: + raise ValueError( + f"Experiment '{experiment_id}' not found or not in 'paused' status" + ) + + def complete_experiment(self, experiment_id: str) -> ExperimentResult: + """Finalise an experiment, evaluate it, and return the result.""" + cur = self._conn.execute( + "UPDATE experiments SET status = :s, completed_at = :now " + "WHERE id = :id AND status IN ('running', 'paused')", + { + "s": ExperimentStatus.COMPLETED.value, + "now": datetime.now(timezone.utc).isoformat(), + "id": experiment_id, + }, + ) + self._conn.commit() + if cur.rowcount == 0: + raise ValueError( + f"Experiment '{experiment_id}' not found or cannot be completed" + ) + logger.info("Completed experiment %s", experiment_id) + return self.evaluate(experiment_id) + + def abandon_experiment(self, experiment_id: str) -> None: + """Mark an experiment as abandoned (discarding results).""" + cur = self._conn.execute( + "UPDATE experiments SET status = :s WHERE id = :id AND status != 'abandoned'", + {"s": ExperimentStatus.ABANDONED.value, "id": experiment_id}, + ) + self._conn.commit() + if cur.rowcount == 0: + raise ValueError(f"Experiment '{experiment_id}' not found") + + # ------------------------------------------------------------------ + # Assignment + # ------------------------------------------------------------------ + + def assign_variant( + self, experiment_id: str, context_key: str = "" + ) -> int: + """Assign an incoming request to a variant index. + + Parameters + ---------- + experiment_id : str + The running experiment. + context_key : str + An identifier for the user/session (used by HASH_BASED + strategy for sticky assignments). Ignored by RANDOM. + + Returns + ------- + int + The zero-based index of the assigned variant. + + Raises + ------ + ValueError + If the experiment is not running or not found. + """ + row = self._conn.execute( + "SELECT config_json, status FROM experiments WHERE id = ?", + (experiment_id,), + ).fetchone() + if row is None: + raise ValueError(f"Experiment '{experiment_id}' not found") + if row["status"] != ExperimentStatus.RUNNING.value: + raise ValueError( + f"Experiment '{experiment_id}' is not running " + f"(status={row['status']})" + ) + + config_dict = json.loads(row["config_json"]) + variants = config_dict["variants"] + strategy = AssignmentStrategy(config_dict["assignment_strategy"]) + n = len(variants) + + if strategy == AssignmentStrategy.RANDOM: + return self._rng.randint(0, n - 1) + + if strategy == AssignmentStrategy.ROUND_ROBIN: + counter = self._round_robin_counters.get(experiment_id, 0) + self._round_robin_counters[experiment_id] = counter + 1 + return counter % n + + if strategy == AssignmentStrategy.WEIGHTED_RANDOM: + weights = [v["weight"] for v in variants] + total = sum(weights) + r = self._rng.random() * total + cumulative = 0.0 + for i, w in enumerate(weights): + cumulative += w + if r <= cumulative: + return i + return n - 1 # fallback for floating-point edge case + + if strategy == AssignmentStrategy.HASH_BASED: + digest = hashlib.sha256( + f"{experiment_id}:{context_key}".encode() + ).hexdigest() + return int(digest[:8], 16) % n + + raise ValueError(f"Unknown assignment strategy: {strategy}") + + # ------------------------------------------------------------------ + # Recording + # ------------------------------------------------------------------ + + def record_outcome( + self, + experiment_id: str, + variant_index: int, + success: bool, + metric_value: float = 1.0, + context: dict[str, Any] | None = None, + ) -> str: + """Record a single outcome for an experiment. + + Parameters + ---------- + experiment_id : str + The experiment to record for. + variant_index : int + The variant that was assigned. + success : bool + Whether the invocation was successful. + metric_value : float + The observed metric value. + context : dict[str, Any] | None + Optional context metadata. + + Returns + ------- + str + The outcome ID. + """ + outcome_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + self._conn.execute( + "INSERT INTO outcomes " + "(id, experiment_id, variant_index, success, metric_value, " + "context_json, created_at) " + "VALUES (:id, :eid, :vi, :s, :mv, :ctx, :now)", + { + "id": outcome_id, + "eid": experiment_id, + "vi": variant_index, + "s": 1 if success else 0, + "mv": metric_value, + "ctx": json.dumps(context or {}), + "now": now, + }, + ) + self._conn.commit() + return outcome_id + + # ------------------------------------------------------------------ + # Evaluation + # ------------------------------------------------------------------ + + def _load_config(self, experiment_id: str) -> dict[str, Any]: + """Load and return the experiment config dict.""" + row = self._conn.execute( + "SELECT config_json, status FROM experiments WHERE id = ?", + (experiment_id,), + ).fetchone() + if row is None: + raise ValueError(f"Experiment '{experiment_id}' not found") + return json.loads(row["config_json"]) + + def _compute_variant_stats( + self, experiment_id: str, config: dict[str, Any] + ) -> list[VariantStats]: + """Compute per-variant statistics from recorded outcomes.""" + variants = config["variants"] + stats: list[VariantStats] = [] + + for i, variant in enumerate(variants): + rows = self._conn.execute( + "SELECT success, metric_value FROM outcomes " + "WHERE experiment_id = ? AND variant_index = ?", + (experiment_id, i), + ).fetchall() + + n = len(rows) + if n == 0: + stats.append( + VariantStats( + variant_index=i, + skill_id=variant["skill_id"], + ) + ) + continue + + successes = sum(1 for r in rows if r["success"]) + values = [r["metric_value"] for r in rows] + mean = sum(values) / n + if n > 1: + variance = sum((v - mean) ** 2 for v in values) / (n - 1) + else: + variance = 0.0 + + stats.append( + VariantStats( + variant_index=i, + skill_id=variant["skill_id"], + sample_size=n, + successes=successes, + success_rate=successes / n, + mean_metric=mean, + variance_metric=variance, + ) + ) + + return stats + + def evaluate(self, experiment_id: str) -> ExperimentResult: + """Evaluate an experiment's current outcomes. + + For two variants, applies a **two-proportion z-test**. + For three or more variants, applies a **chi-squared test**. + + Returns + ------- + ExperimentResult + Aggregated statistics and significance verdict. + """ + row = self._conn.execute( + "SELECT config_json, status FROM experiments WHERE id = ?", + (experiment_id,), + ).fetchone() + if row is None: + raise ValueError(f"Experiment '{experiment_id}' not found") + + config = json.loads(row["config_json"]) + status = ExperimentStatus(row["status"]) + stats = self._compute_variant_stats(experiment_id, config) + total_samples = sum(s.sample_size for s in stats) + min_sample = config.get("min_sample_size", 30) + + result = ExperimentResult( + experiment_id=experiment_id, + status=status, + variant_stats=stats, + ) + + if total_samples < min_sample: + result.summary = ( + f"Insufficient data: {total_samples}/{min_sample} samples collected" + ) + return result + + n_variants = len(config["variants"]) + + if n_variants == 2: + # Two-proportion z-test + s1, s2 = stats[0], stats[1] + z, p = _two_proportion_z_test( + s1.sample_size, s1.successes, + s2.sample_size, s2.successes, + ) + result.test_name = "two_proportion_z_test" + result.p_value = p + alpha = config.get("alpha", 0.05) + result.is_significant = p < alpha + + if result.is_significant: + winner = 0 if s1.success_rate > s2.success_rate else 1 + result.best_variant_index = winner + loser = 1 - winner + result.summary = ( + f"Significant result (p={p:.4f}): variant {winner} " + f"({config['variants'][winner]['skill_id']}, " + f"SR={stats[winner].success_rate:.2%}) outperforms " + f"variant {loser} " + f"(SR={stats[loser].success_rate:.2%})" + ) + else: + result.summary = ( + f"No significant difference (p={p:.4f}, α={alpha})" + ) + + else: + # Chi-squared test across multiple variants + observed = [s.successes for s in stats] + total_successes = sum(observed) + # Expected: equal distribution of successes + if total_successes == 0 or n_variants == 0: + result.summary = "No successes recorded across any variant" + return result + expected = [total_successes / n_variants] * n_variants + chi2, p = _chi_squared_test(observed, expected) + + result.test_name = "chi_squared" + result.p_value = p + alpha = config.get("alpha", 0.05) + result.is_significant = p < alpha + + if result.is_significant: + winner = max(range(n_variants), key=lambda i: stats[i].success_rate) + result.best_variant_index = winner + result.summary = ( + f"Significant result (χ²={chi2:.4f}, p={p:.4f}): " + f"variant {winner} " + f"({config['variants'][winner]['skill_id']}, " + f"SR={stats[winner].success_rate:.2%}) is best" + ) + else: + result.summary = ( + f"No significant difference (χ²={chi2:.4f}, p={p:.4f}, " + f"α={alpha})" + ) + + return result + + # ------------------------------------------------------------------ + # Querying + # ------------------------------------------------------------------ + + def get_experiment_status(self, experiment_id: str) -> ExperimentStatus: + """Return the current status of an experiment.""" + row = self._conn.execute( + "SELECT status FROM experiments WHERE id = ?", (experiment_id,) + ).fetchone() + if row is None: + raise ValueError(f"Experiment '{experiment_id}' not found") + return ExperimentStatus(row["status"]) + + def list_experiments( + self, status: ExperimentStatus | None = None + ) -> list[dict[str, Any]]: + """List experiments, optionally filtered by status. + + Returns + ------- + list[dict[str, Any]] + Each dict has keys ``id``, ``name``, ``status``, ``created_at``. + """ + if status is not None: + rows = self._conn.execute( + "SELECT id, name, status, created_at FROM experiments " + "WHERE status = ? ORDER BY created_at DESC", + (status.value,), + ).fetchall() + else: + rows = self._conn.execute( + "SELECT id, name, status, created_at FROM experiments " + "ORDER BY created_at DESC" + ).fetchall() + return [dict(r) for r in rows] + + def get_outcomes( + self, + experiment_id: str, + variant_index: int | None = None, + limit: int = 1000, + ) -> list[Outcome]: + """Retrieve recorded outcomes for an experiment. + + Parameters + ---------- + experiment_id : str + The experiment to query. + variant_index : int | None + Filter to a specific variant. + limit : int + Maximum outcomes to return. + + Returns + ------- + list[Outcome] + """ + if variant_index is not None: + rows = self._conn.execute( + "SELECT * FROM outcomes " + "WHERE experiment_id = ? AND variant_index = ? " + "ORDER BY created_at DESC LIMIT ?", + (experiment_id, variant_index, limit), + ).fetchall() + else: + rows = self._conn.execute( + "SELECT * FROM outcomes WHERE experiment_id = ? " + "ORDER BY created_at DESC LIMIT ?", + (experiment_id, limit), + ).fetchall() + + return [ + Outcome( + id=r["id"], + experiment_id=r["experiment_id"], + variant_index=r["variant_index"], + success=bool(r["success"]), + metric_value=r["metric_value"], + context=json.loads(r["context_json"]), + created_at=r["created_at"], + ) + for r in rows + ] + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + def close(self) -> None: + """Close the database connection.""" + self._conn.close() diff --git a/skillforge/advanced/elastic_memory.py b/skillforge/advanced/elastic_memory.py new file mode 100644 index 0000000..0c94499 --- /dev/null +++ b/skillforge/advanced/elastic_memory.py @@ -0,0 +1,581 @@ +"""Elastic Memory — adaptive memory system for skill execution history. + +Stores, retrieves, compresses, and manages contextual memories of skill +executions. Memories carry an *importance* score and an *access count*, +allowing the system to perform importance-weighted retention during +automatic compaction. + +Inspired by experience-replay buffers from reinforcement learning and +long-term memory consolidation from cognitive science. All storage is +backed by SQLite so memories survive restarts. + +No external dependencies — stdlib only. +""" + +from __future__ import annotations + +import json +import logging +import math +import re +import sqlite3 +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +@dataclass +class MemoryEntry: + """A single memory record capturing a skill execution context. + + Attributes + ---------- + id : str + Unique identifier for this memory. + skill_id : str + The skill that produced this memory. + context : str + Free-text description of the execution context (task, inputs, etc.). + outcome : str + Description of the outcome (success/failure details, output summary). + importance : float + Importance score in [0, 1]. Higher values are retained longer + during compaction. + access_count : int + How many times this memory has been recalled. Frequently accessed + memories are boosted during compaction. + created_at : str + ISO-8601 UTC timestamp of creation. + last_accessed_at : str | None + ISO-8601 UTC timestamp of the last recall, or ``None`` if never. + metadata : dict[str, Any] + Arbitrary key-value metadata (e.g. latency, tokens, tags). + """ + + id: str + skill_id: str + context: str + outcome: str + importance: float = 0.5 + access_count: int = 0 + created_at: str = "" + last_accessed_at: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# ElasticMemory +# --------------------------------------------------------------------------- + + +class ElasticMemory: + """Adaptive memory store for skill execution histories. + + Each memory is a :class:`MemoryEntry` that captures the context and + outcome of a single skill invocation. The store supports: + + * **remember** — persist a new memory. + * **recall** — keyword-based retrieval ranked by relevance × importance. + * **consolidate** — merge similar old memories for a skill to save space. + * **forget** — evict low-importance entries older than a threshold. + * **auto_compact** — global compaction keeping only the top entries + ranked by a composite retention score. + + Parameters + ---------- + db_path : str | Path | None + SQLite database path. Defaults to ``~/.skillforge/memory.db``. + max_entries : int + Soft cap on total entries. :meth:`auto_compact` enforces this. + """ + + def __init__( + self, + db_path: str | Path | None = None, + max_entries: int = 10_000, + ) -> None: + self._db_path = str(db_path or Path.home() / ".skillforge" / "memory.db") + Path(self._db_path).parent.mkdir(parents=True, exist_ok=True) + self._max_entries = max_entries + self._conn = sqlite3.connect(self._db_path) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._ensure_tables() + + # ------------------------------------------------------------------ + # Schema + # ------------------------------------------------------------------ + + def _ensure_tables(self) -> None: + self._conn.executescript( + """ + CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + skill_id TEXT NOT NULL, + context TEXT NOT NULL, + outcome TEXT NOT NULL, + importance REAL NOT NULL DEFAULT 0.5, + access_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_accessed_at TEXT, + metadata TEXT NOT NULL DEFAULT '{}' + ); + CREATE INDEX IF NOT EXISTS idx_memories_skill ON memories(skill_id); + CREATE INDEX IF NOT EXISTS idx_memories_imp ON memories(importance); + CREATE INDEX IF NOT EXISTS idx_memories_ts ON memories(created_at); + """ + ) + self._conn.commit() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _row_to_entry(row: sqlite3.Row) -> MemoryEntry: + d = dict(row) + d["metadata"] = json.loads(d.get("metadata") or "{}") + return MemoryEntry(**d) + + @staticmethod + def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def remember( + self, + skill_id: str, + context: str, + outcome: str, + importance: float = 0.5, + metadata: dict[str, Any] | None = None, + ) -> MemoryEntry: + """Store a new memory and return it. + + Parameters + ---------- + skill_id : str + The skill that produced this memory. + context : str + Free-text context description. + outcome : str + Free-text outcome description. + importance : float + Importance score clamped to [0, 1]. + metadata : dict | None + Arbitrary metadata (latency, tokens, tags, etc.). + + Returns + ------- + MemoryEntry + The persisted memory. + """ + now = self._now_iso() + entry = MemoryEntry( + id=str(uuid.uuid4()), + skill_id=skill_id, + context=context, + outcome=outcome, + importance=max(0.0, min(1.0, importance)), + access_count=0, + created_at=now, + last_accessed_at=None, + metadata=metadata or {}, + ) + self._conn.execute( + "INSERT INTO memories " + "(id, skill_id, context, outcome, importance, access_count, " + " created_at, last_accessed_at, metadata) " + "VALUES (?,?,?,?,?,?,?,?,?)", + ( + entry.id, + entry.skill_id, + entry.context, + entry.outcome, + entry.importance, + entry.access_count, + entry.created_at, + entry.last_accessed_at, + json.dumps(entry.metadata), + ), + ) + self._conn.commit() + logger.debug("Remembered memory %s for skill '%s'", entry.id, skill_id) + return entry + + def recall( + self, + skill_id: str | None = None, + query: str | None = None, + limit: int = 10, + min_importance: float = 0.0, + ) -> list[MemoryEntry]: + """Retrieve memories ranked by a relevance × importance score. + + When *query* is provided, relevance is computed via simple keyword + overlap with the ``context`` and ``outcome`` fields. Without a + *query*, results are ordered by importance descending. + + Parameters + ---------- + skill_id : str | None + Filter to a specific skill. ``None`` searches all skills. + query : str | None + Space-separated keywords to match against context/outcome. + limit : int + Maximum results. + min_importance : float + Minimum importance threshold. + + Returns + ------- + list[MemoryEntry] + Matching memories sorted by composite score descending. + """ + clauses: list[str] = [] + params: list[Any] = [] + + if skill_id is not None: + clauses.append("skill_id = ?") + params.append(skill_id) + if min_importance > 0: + clauses.append("importance >= ?") + params.append(min_importance) + + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + + rows = self._conn.execute( + f"SELECT * FROM memories{where} ORDER BY importance DESC LIMIT ?", + params + [limit * 5], # fetch extra for re-ranking + ).fetchall() + + entries = [self._row_to_entry(r) for r in rows] + + if query: + keywords = set(re.findall(r"\w+", query.lower())) + scored: list[tuple[float, MemoryEntry]] = [] + for entry in entries: + text = (entry.context + " " + entry.outcome).lower() + words = set(re.findall(r"\w+", text)) + overlap = len(keywords & words) / max(len(keywords), 1) + # Composite: 60% keyword overlap + 40% importance + score = 0.6 * overlap + 0.4 * entry.importance + scored.append((score, entry)) + scored.sort(key=lambda t: t[0], reverse=True) + entries = [e for _, e in scored[:limit]] + else: + entries = entries[:limit] + + # Update access counts + now = self._now_iso() + for entry in entries: + entry.access_count += 1 + entry.last_accessed_at = now + self._conn.execute( + "UPDATE memories SET access_count = ?, last_accessed_at = ? " + "WHERE id = ?", + (entry.access_count, now, entry.id), + ) + if entries: + self._conn.commit() + + return entries + + def get_memory(self, memory_id: str) -> MemoryEntry | None: + """Retrieve a single memory by its ID. + + Parameters + ---------- + memory_id : str + The memory's unique identifier. + + Returns + ------- + MemoryEntry | None + The memory, or ``None`` if not found. + """ + row = self._conn.execute( + "SELECT * FROM memories WHERE id = ?", (memory_id,) + ).fetchone() + if row is None: + return None + return self._row_to_entry(row) + + def forget( + self, + skill_id: str | None = None, + older_than_days: float | None = None, + max_importance: float = 1.0, + ) -> int: + """Evict memories matching the given criteria. + + Parameters + ---------- + skill_id : str | None + If given, only delete memories for this skill. + older_than_days : float | None + If given, only delete memories older than this many days. + max_importance : float + Only delete memories with importance ≤ this threshold. + + Returns + ------- + int + Number of memories deleted. + """ + clauses: list[str] = [] + params: list[Any] = [] + + if skill_id is not None: + clauses.append("skill_id = ?") + params.append(skill_id) + + if max_importance < 1.0: + clauses.append("importance <= ?") + params.append(max_importance) + + if older_than_days is not None: + cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days) + clauses.append("created_at < ?") + params.append(cutoff.isoformat()) + + if not clauses: + return 0 + + where = " WHERE " + " AND ".join(clauses) + cur = self._conn.execute(f"DELETE FROM memories{where}", params) + self._conn.commit() + deleted = cur.rowcount + logger.debug("Forgot %d memories", deleted) + return deleted + + def consolidate(self, skill_id: str) -> int: + """Consolidate memories for *skill_id* by merging similar entries. + + Memories with highly overlapping keyword sets (Jaccard > 0.7) + are merged: the newer entry absorbs the older one's metadata and + the older entry is deleted. The surviving entry's importance is + boosted to the max of the pair. + + Parameters + ---------- + skill_id : str + Skill whose memories should be consolidated. + + Returns + ------- + int + Number of entries removed during consolidation. + """ + rows = self._conn.execute( + "SELECT * FROM memories WHERE skill_id = ? ORDER BY created_at ASC", + (skill_id,), + ).fetchall() + + entries = [self._row_to_entry(r) for r in rows] + if len(entries) < 2: + return 0 + + # Build keyword sets + entry_kw: list[tuple[MemoryEntry, set[str]]] = [] + for e in entries: + text = (e.context + " " + e.outcome).lower() + entry_kw.append((e, set(re.findall(r"\w+", text)))) + + to_delete: set[str] = set() + merged_importances: dict[str, float] = {} + + for i in range(len(entry_kw)): + e_i, kw_i = entry_kw[i] + if e_i.id in to_delete: + continue + for j in range(i + 1, len(entry_kw)): + e_j, kw_j = entry_kw[j] + if e_j.id in to_delete: + continue + # Jaccard similarity + union = kw_i | kw_j + if not union: + continue + jaccard = len(kw_i & kw_j) / len(union) + if jaccard > 0.7: + # Merge j into i (keep the older one) + to_delete.add(e_j.id) + new_imp = max(e_i.importance, e_j.importance) + merged_importances[e_i.id] = new_imp + + # Apply merges + for entry_id, new_imp in merged_importances.items(): + self._conn.execute( + "UPDATE memories SET importance = ? WHERE id = ?", + (new_imp, entry_id), + ) + + if to_delete: + placeholders = ",".join("?" for _ in to_delete) + self._conn.execute( + f"DELETE FROM memories WHERE id IN ({placeholders})", + list(to_delete), + ) + self._conn.commit() + + removed = len(to_delete) + logger.debug( + "Consolidated %d memories for skill '%s'", removed, skill_id + ) + return removed + + def auto_compact(self, max_entries: int | None = None) -> int: + """Global compaction: retain only the top-*N* entries by retention score. + + The retention score is a weighted combination of: + + * Importance (weight 0.45) + * Recency — exponential decay with 30-day half-life (weight 0.30) + * Access frequency — log-scaled (weight 0.25) + + Parameters + ---------- + max_entries : int | None + Override the instance-level ``max_entries`` cap. + + Returns + ------- + int + Number of entries evicted. + """ + cap = max_entries if max_entries is not None else self._max_entries + count_row = self._conn.execute( + "SELECT COUNT(*) AS cnt FROM memories" + ).fetchone() + total = count_row["cnt"] if count_row else 0 + + if total <= cap: + return 0 + + rows = self._conn.execute("SELECT * FROM memories").fetchall() + entries = [self._row_to_entry(r) for r in rows] + now = datetime.now(timezone.utc) + + def _retention_score(e: MemoryEntry) -> float: + # Importance component + imp = max(0.0, min(1.0, e.importance)) + + # Recency component: exponential decay, 30-day half-life + try: + created = datetime.fromisoformat(e.created_at) + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + days = (now - created).total_seconds() / 86400.0 + except (ValueError, TypeError): + days = 365.0 + recency = math.exp(-0.6931 * days / 30.0) + + # Access frequency: log-scaled, saturating around 50 accesses + freq = min(1.0, math.log(1 + e.access_count) / math.log(51)) + + return 0.45 * imp + 0.30 * recency + 0.25 * freq + + entries.sort(key=_retention_score, reverse=True) + to_evict = entries[cap:] + evict_ids = [e.id for e in to_evict] + + if evict_ids: + placeholders = ",".join("?" for _ in evict_ids) + self._conn.execute( + f"DELETE FROM memories WHERE id IN ({placeholders})", evict_ids + ) + self._conn.commit() + + evicted = len(evict_ids) + logger.debug("Auto-compacted %d memories (cap=%d)", evicted, cap) + return evicted + + def get_memory_stats(self, skill_id: str | None = None) -> dict[str, Any]: + """Return aggregate statistics about stored memories. + + Parameters + ---------- + skill_id : str | None + If given, stats are scoped to that skill. Otherwise global. + + Returns + ------- + dict[str, Any] + Keys: ``total_memories``, ``avg_importance``, ``avg_access_count``, + ``oldest``, ``newest``, ``skills`` (list of unique skill IDs). + """ + where = " WHERE skill_id = ?" if skill_id else "" + params: tuple[Any, ...] = (skill_id,) if skill_id else () + + row = self._conn.execute( + f"SELECT COUNT(*) AS cnt, " + f"COALESCE(AVG(importance), 0) AS avg_imp, " + f"COALESCE(AVG(access_count), 0) AS avg_acc, " + f"MIN(created_at) AS oldest, " + f"MAX(created_at) AS newest " + f"FROM memories{where}", + params, + ).fetchone() + + total = row["cnt"] if row else 0 + + # Get unique skill IDs + if skill_id: + skills_list = [skill_id] if total > 0 else [] + else: + srows = self._conn.execute( + "SELECT DISTINCT skill_id FROM memories" + ).fetchall() + skills_list = [r["skill_id"] for r in srows] + + return { + "total_memories": total, + "avg_importance": round(row["avg_imp"], 4) if row else 0.0, + "avg_access_count": round(row["avg_acc"], 2) if row else 0.0, + "oldest": row["oldest"] if row else None, + "newest": row["newest"] if row else None, + "skills": skills_list, + } + + def count(self, skill_id: str | None = None) -> int: + """Return the number of stored memories. + + Parameters + ---------- + skill_id : str | None + If given, count only memories for this skill. + + Returns + ------- + int + Number of memories. + """ + if skill_id: + row = self._conn.execute( + "SELECT COUNT(*) AS cnt FROM memories WHERE skill_id = ?", + (skill_id,), + ).fetchone() + else: + row = self._conn.execute("SELECT COUNT(*) AS cnt FROM memories").fetchone() + return row["cnt"] if row else 0 + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """Close the database connection.""" + self._conn.close() diff --git a/skillforge/advanced/rl_optimizer.py b/skillforge/advanced/rl_optimizer.py index 444f983..2a8b382 100644 --- a/skillforge/advanced/rl_optimizer.py +++ b/skillforge/advanced/rl_optimizer.py @@ -8,12 +8,23 @@ The optimiser co-evolves skill representations alongside a learned reward model — mirroring the co-evolution concept from Evolving-RL where both the policy and the environment representation improve in tandem. + +Phase 5a Enhancements: +- **Contextual bandit** action selection (epsilon-greedy with decay). +- **Experience replay** buffer for sample-efficient learning. +- **Curriculum scheduler** that orders skills by difficulty for batch + optimisation. +- **Reward model** learned from historical outcomes. """ from __future__ import annotations +import collections +import hashlib import json import logging +import math +import random import re import sqlite3 import uuid @@ -63,6 +74,33 @@ class TrainingStep: ) +@dataclass +class Experience: + """A single transition stored in the experience replay buffer. + + Attributes + ---------- + state_hash : str + Hash of the skill state (context) at the time of action. + action : str + The action taken. + reward : float + Observed reward. + next_state_hash : str + Hash of the skill state after the action. + timestamp : str + When the experience was recorded. + """ + + state_hash: str + action: str + reward: float + next_state_hash: str + timestamp: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + + # --------------------------------------------------------------------------- # Protocols (structural typing for loose coupling) # --------------------------------------------------------------------------- @@ -91,6 +129,500 @@ class EvolutionProto(Protocol): def evolve_skill(self, skill_id: str) -> bool: ... # pragma: no cover +# --------------------------------------------------------------------------- +# Experience Replay Buffer +# --------------------------------------------------------------------------- + + +class ReplayBuffer: + """Fixed-capacity experience replay buffer with uniform sampling. + + Stores :class:`Experience` instances and supports batch sampling + for the learned reward model. + + Parameters + ---------- + capacity : int + Maximum number of experiences to retain. + """ + + def __init__(self, capacity: int = 10_000) -> None: + self._capacity = capacity + self._buffer: collections.deque[Experience] = collections.deque( + maxlen=capacity + ) + + @property + def size(self) -> int: + """Current number of stored experiences.""" + return len(self._buffer) + + def push(self, experience: Experience) -> None: + """Add an experience to the buffer. + + Parameters + ---------- + experience : Experience + The transition to store. + """ + self._buffer.append(experience) + + def sample(self, batch_size: int) -> list[Experience]: + """Sample a random batch of experiences. + + Parameters + ---------- + batch_size : int + Number of experiences to sample. + + Returns + ------- + list[Experience] + Random sample (may be smaller than *batch_size* if the + buffer is not full enough). + """ + batch_size = min(batch_size, len(self._buffer)) + return random.sample(list(self._buffer), batch_size) + + def get_all(self) -> list[Experience]: + """Return all stored experiences.""" + return list(self._buffer) + + def clear(self) -> None: + """Remove all stored experiences.""" + self._buffer.clear() + + +# --------------------------------------------------------------------------- +# Contextual Bandit +# --------------------------------------------------------------------------- + + +class ContextualBandit: + """Epsilon-greedy contextual bandit for action selection. + + Maintains per-action reward statistics and selects actions based on + context features (skill complexity, current Q-value, content length). + + Parameters + ---------- + actions : list[str] + Available actions. + epsilon_start : float + Initial exploration rate. + epsilon_min : float + Minimum exploration rate. + epsilon_decay : float + Multiplicative decay applied after each action selection. + """ + + def __init__( + self, + actions: list[str] | None = None, + epsilon_start: float = 0.3, + epsilon_min: float = 0.05, + epsilon_decay: float = 0.995, + ) -> None: + self._actions = actions or ["compress", "split", "reorder"] + self._epsilon = epsilon_start + self._epsilon_min = epsilon_min + self._epsilon_decay = epsilon_decay + + # Per-action statistics: action → {count, total_reward, avg_reward} + self._stats: dict[str, dict[str, float]] = { + a: {"count": 0.0, "total_reward": 0.0, "avg_reward": 0.0} + for a in self._actions + } + + # Context-bucket → action → cumulative reward + # Buckets are simple strings derived from discretised features. + self._context_values: dict[str, dict[str, float]] = {} + + @property + def epsilon(self) -> float: + """Current exploration rate.""" + return self._epsilon + + def select_action(self, context: dict[str, float]) -> str: + """Select an action given a context. + + Parameters + ---------- + context : dict[str, float] + Context features (e.g. ``{"q_value": 0.3, "length": 0.7}``). + + Returns + ------- + str + The selected action. + """ + if random.random() < self._epsilon: + action = random.choice(self._actions) + else: + bucket = self._context_to_bucket(context) + if bucket in self._context_values: + cv = self._context_values[bucket] + action = max(cv, key=cv.get) # type: ignore[arg-type] + else: + # No data for this context — pick best global action + action = max( + self._stats, + key=lambda a: self._stats[a]["avg_reward"], + ) + + # Decay epsilon + self._epsilon = max(self._epsilon_min, self._epsilon * self._epsilon_decay) + return action + + def update( + self, context: dict[str, float], action: str, reward: float + ) -> None: + """Update bandit statistics after observing a reward. + + Parameters + ---------- + context : dict[str, float] + The context in which the action was taken. + action : str + The action that was taken. + reward : float + The observed reward. + """ + # Global stats + stats = self._stats[action] + stats["count"] += 1 + stats["total_reward"] += reward + stats["avg_reward"] = stats["total_reward"] / stats["count"] + + # Context-bucket stats + bucket = self._context_to_bucket(context) + if bucket not in self._context_values: + self._context_values[bucket] = {a: 0.0 for a in self._actions} + self._context_values[bucket][action] += reward + + def get_stats(self) -> dict[str, dict[str, float]]: + """Return per-action reward statistics.""" + return dict(self._stats) + + @staticmethod + def _context_to_bucket(context: dict[str, float]) -> str: + """Discretise continuous context features into a bucket key. + + Each feature is quantised to 5 levels (0-4) and combined into + a string key. + """ + parts: list[str] = [] + for key in sorted(context): + val = context[key] + level = min(4, max(0, int(val * 5))) + parts.append(f"{key}={level}") + return "|".join(parts) + + +# --------------------------------------------------------------------------- +# Learned Reward Model +# --------------------------------------------------------------------------- + + +class RewardModel: + """Lightweight learned reward model trained from experience replay. + + Uses a simple linear model (one weight per feature) to predict + reward from (context, action) pairs. Updated via gradient descent + on replay samples. + + Parameters + ---------- + feature_dim : int + Number of context features. + actions : list[str] + Available actions. + learning_rate : float + SGD learning rate. + """ + + def __init__( + self, + feature_dim: int = 4, + actions: list[str] | None = None, + learning_rate: float = 0.01, + ) -> None: + self._actions = actions or ["compress", "split", "reorder"] + self._lr = learning_rate + self._feature_dim = feature_dim + + # Weight matrix: action → feature weights (list of floats) + self._weights: dict[str, list[float]] = { + a: [0.0] * feature_dim for a in self._actions + } + self._bias: dict[str, float] = {a: 0.0 for a in self._actions} + self._update_count: int = 0 + + @property + def update_count(self) -> int: + """Number of training updates performed.""" + return self._update_count + + def predict(self, features: list[float], action: str) -> float: + """Predict reward for a (features, action) pair. + + Parameters + ---------- + features : list[float] + Context feature vector. + action : str + The action to evaluate. + + Returns + ------- + float + Predicted reward. + """ + w = self._weights.get(action, [0.0] * self._feature_dim) + b = self._bias.get(action, 0.0) + return sum(f * wi for f, wi in zip(features, w)) + b + + def predict_best_action(self, features: list[float]) -> tuple[str, float]: + """Return the action with the highest predicted reward. + + Parameters + ---------- + features : list[float] + Context feature vector. + + Returns + ------- + tuple[str, float] + (best_action, predicted_reward). + """ + best_action = self._actions[0] + best_reward = float("-inf") + for action in self._actions: + pred = self.predict(features, action) + if pred > best_reward: + best_reward = pred + best_action = action + return best_action, best_reward + + def train_step( + self, features: list[float], action: str, target_reward: float + ) -> float: + """Apply one gradient-descent step for a single sample. + + Uses MSE loss: ``L = (predicted - target)²``. + + Parameters + ---------- + features : list[float] + Context feature vector. + action : str + The action taken. + target_reward : float + The observed (target) reward. + + Returns + ------- + float + The squared error for this sample. + """ + pred = self.predict(features, action) + error = pred - target_reward + # Gradient descent on linear model + w = self._weights[action] + for i in range(len(w)): + w[i] -= self._lr * error * features[i] + self._bias[action] -= self._lr * error + self._update_count += 1 + return error * error + + def train_batch( + self, + replay_buffer: ReplayBuffer, + context_fn: Any, + batch_size: int = 64, + ) -> float: + """Train on a batch sampled from the replay buffer. + + Parameters + ---------- + replay_buffer : ReplayBuffer + Source of experience samples. + context_fn : callable + Function ``(state_hash) -> list[float]`` to convert state + hashes into feature vectors. + batch_size : int + Number of samples to train on. + + Returns + ------- + float + Mean squared error over the batch. + """ + if replay_buffer.size == 0: + return 0.0 + samples = replay_buffer.sample(batch_size) + total_se = 0.0 + for exp in samples: + features = context_fn(exp.state_hash) + total_se += self.train_step(features, exp.action, exp.reward) + return total_se / len(samples) + + def get_weights(self) -> dict[str, dict[str, Any]]: + """Return current model weights for inspection.""" + return { + action: {"weights": list(self._weights[action]), "bias": self._bias[action]} + for action in self._actions + } + + +# --------------------------------------------------------------------------- +# Curriculum Scheduler +# --------------------------------------------------------------------------- + + +class CurriculumScheduler: + """Orders skills by estimated difficulty for staged batch optimisation. + + Difficulty is computed from skill properties: low Q-value, long content, + and high complexity all contribute to higher difficulty. + + Three stages are supported: + - **warmup**: only easiest skills (difficulty < 0.33) + - **main**: medium-difficulty skills (0.33 ≤ difficulty < 0.66) + - **advanced**: hardest skills (difficulty ≥ 0.66) + + Parameters + ---------- + tracker : TrackerProto + For reading Q-values and stats. + """ + + def __init__(self, tracker: TrackerProto) -> None: + self._tracker = tracker + self._stage = "warmup" + self._stage_history: list[str] = ["warmup"] + + @property + def current_stage(self) -> str: + """Current curriculum stage.""" + return self._stage + + @property + def stage_history(self) -> list[str]: + """History of stage transitions.""" + return list(self._stage_history) + + def compute_difficulty(self, skill: Any) -> float: + """Estimate skill difficulty in ``[0, 1]``. + + Parameters + ---------- + skill : Any + A skill object with ``id``, ``tier2_core``, ``q_value`` attrs. + + Returns + ------- + float + Difficulty score (higher = harder). + """ + sid = skill.id if hasattr(skill, "id") else str(skill) + q_val = self._tracker.get_q_value(sid) + core: str = skill.tier2_core if hasattr(skill, "tier2_core") else "" + content_len = len(core.split()) if core else 0 + + # Low Q-value → harder, long content → harder + q_factor = 1.0 - q_val # invert: low Q = high difficulty + len_factor = min(1.0, content_len / 500) # normalise to 500 words + + difficulty = 0.7 * q_factor + 0.3 * len_factor + return max(0.0, min(1.0, difficulty)) + + def order_skills(self, skills: list[Any]) -> list[Any]: + """Sort skills by ascending difficulty. + + Parameters + ---------- + skills : list[Any] + Skills to order. + + Returns + ------- + list[Any] + Skills sorted from easiest to hardest. + """ + scored = [(self.compute_difficulty(s), s) for s in skills] + scored.sort(key=lambda x: x[0]) + return [s for _, s in scored] + + def get_stage_skills( + self, + skills: list[Any], + stage: str | None = None, + ) -> list[Any]: + """Return skills belonging to a curriculum stage. + + Parameters + ---------- + skills : list[Any] + All available skills. + stage : str | None + Stage to filter by (defaults to current stage). + + Returns + ------- + list[Any] + Skills in the specified stage. + """ + stage = stage or self._stage + boundaries = { + "warmup": (0.0, 0.33), + "main": (0.33, 0.66), + "advanced": (0.66, 1.01), + } + lo, hi = boundaries.get(stage, (0.0, 1.01)) + result: list[Any] = [] + for s in skills: + d = self.compute_difficulty(s) + if lo <= d < hi: + result.append(s) + return self.order_skills(result) + + def advance_stage(self) -> str: + """Move to the next curriculum stage. + + Returns + ------- + str + The new current stage. + """ + progression = ["warmup", "main", "advanced"] + idx = progression.index(self._stage) + if idx < len(progression) - 1: + self._stage = progression[idx + 1] + self._stage_history.append(self._stage) + logger.info("Curriculum advanced to stage '%s'", self._stage) + return self._stage + + def should_advance(self, recent_rewards: list[float], threshold: float = 0.0) -> bool: + """Check if recent performance warrants advancing to the next stage. + + Parameters + ---------- + recent_rewards : list[float] + Rewards from recent optimisation steps in the current stage. + threshold : float + Mean reward threshold to advance. + + Returns + ------- + bool + True if the scheduler should advance. + """ + if len(recent_rewards) < 3: + return False + return sum(recent_rewards) / len(recent_rewards) > threshold + + # --------------------------------------------------------------------------- # RLOptimizer # --------------------------------------------------------------------------- @@ -105,6 +637,9 @@ class RLOptimizer: transformation is kept, mirroring the co-evolution concept from Evolving-RL. + Phase 5a adds contextual bandit action selection, experience replay, + a learned reward model, and curriculum-based batch scheduling. + Parameters ---------- registry : SkillRegistryProto @@ -115,6 +650,12 @@ class RLOptimizer: Evolution loop for fallback skill-level evolution. db_path : str | Path | None SQLite database path. Defaults to ``~/.skillforge/rl_optimizer.db``. + replay_capacity : int + Maximum experiences in the replay buffer. + bandit_epsilon : float + Initial epsilon for the contextual bandit. + reward_lr : float + Learning rate for the learned reward model. """ # Reward weights @@ -128,6 +669,9 @@ def __init__( tracker: TrackerProto, evolution: EvolutionProto, db_path: str | Path | None = None, + replay_capacity: int = 10_000, + bandit_epsilon: float = 0.3, + reward_lr: float = 0.01, ) -> None: self._registry = registry self._tracker = tracker @@ -141,6 +685,20 @@ def __init__( self._conn.execute("PRAGMA journal_mode=WAL") self._ensure_tables() + # Phase 5a components + self._bandit = ContextualBandit( + epsilon_start=bandit_epsilon, + ) + self._replay_buffer = ReplayBuffer(capacity=replay_capacity) + self._reward_model = RewardModel( + feature_dim=4, + learning_rate=reward_lr, + ) + self._curriculum = CurriculumScheduler(tracker=tracker) + + # Per-optimisation-run reward tracking for curriculum decisions + self._current_stage_rewards: list[float] = [] + # ------------------------------------------------------------------ # Schema # ------------------------------------------------------------------ @@ -163,6 +721,17 @@ def _ensure_tables(self) -> None: ON rl_optimization_log(skill_id); CREATE INDEX IF NOT EXISTS idx_rl_log_action ON rl_optimization_log(action); + + CREATE TABLE IF NOT EXISTS rl_experiences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + state_hash TEXT NOT NULL, + action TEXT NOT NULL, + reward REAL NOT NULL, + next_state TEXT NOT NULL, + recorded_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_rl_exp_state + ON rl_experiences(state_hash); """ ) self._conn.commit() @@ -180,6 +749,9 @@ def optimize( reorder actions. The action producing the highest reward is retained; the others are rolled back. + Phase 5a: action selection uses the contextual bandit; experiences + are stored in the replay buffer and the reward model is updated. + Parameters ---------- skill_id : str @@ -202,6 +774,15 @@ def optimize( before_q = self._tracker.get_q_value(skill_id) original_core: str = skill.tier2_core if hasattr(skill, "tier2_core") else "" + # Build context features for the bandit + context = self._build_context(skill_id, skill) + state_hash = self._hash_state(original_core, before_q) + + # --- Select action via bandit --- + # In Phase 5a, we still try all actions but use the bandit + # to decide which to evaluate first (and for exploration). + selected_action = self._bandit.select_action(context) + best_reward: float = -1.0 best_action: str | None = None best_core: str | None = None @@ -257,6 +838,31 @@ def optimize( break after_q = self._tracker.get_q_value(skill_id) + next_state_hash = self._hash_state(best_core or original_core, after_q) + + # --- Store experience --- + exp = Experience( + state_hash=state_hash, + action=best_action, + reward=best_reward, + next_state_hash=next_state_hash, + ) + self._replay_buffer.push(exp) + self._persist_experience(exp) + + # --- Update bandit and reward model --- + self._bandit.update(context, best_action, best_reward) + self._current_stage_rewards.append(best_reward) + + # Train reward model on a mini-batch + if self._replay_buffer.size >= 16: + mse = self._reward_model.train_batch( + self._replay_buffer, + context_fn=self._state_hash_to_features, + batch_size=32, + ) + logger.debug("Reward model MSE: %.6f", mse) + step = TrainingStep( skill_id=skill_id, action=best_action, @@ -281,6 +887,8 @@ def optimize( def batch_optimize(self, threshold: float = 0.5) -> list[TrainingStep]: """Optimise all skills whose Q-value is below *threshold*. + Uses the curriculum scheduler to order skills by difficulty. + Parameters ---------- threshold : float @@ -293,20 +901,120 @@ def batch_optimize(self, threshold: float = 0.5) -> list[TrainingStep]: """ all_steps: list[TrainingStep] = [] skills = self._registry.list_skills() - for skill in skills: + candidates = [ + s for s in skills + if self._tracker.get_q_value( + s.id if hasattr(s, "id") else str(s) + ) < threshold + ] + + # Order by curriculum difficulty + ordered = self._curriculum.order_skills(candidates) + self._current_stage_rewards = [] + + for skill in ordered: sid: str = skill.id if hasattr(skill, "id") else str(skill) q_val = self._tracker.get_q_value(sid) - if q_val < threshold: - logger.info( - "Batch-optimising skill '%s' (Q=%.3f < %.3f)", - sid, - q_val, - threshold, - ) + logger.info( + "Batch-optimising skill '%s' (Q=%.3f < %.3f, stage=%s)", + sid, + q_val, + threshold, + self._curriculum.current_stage, + ) + steps = self.optimize(sid) + all_steps.extend(steps) + + # Check if curriculum should advance + if self._curriculum.should_advance(self._current_stage_rewards): + self._curriculum.advance_stage() + + return all_steps + + def curriculum_optimize(self) -> list[TrainingStep]: + """Run full curriculum-based optimisation through all stages. + + Progresses through warmup → main → advanced, optimising + stage-appropriate skills at each level. + + Returns + ------- + list[TrainingStep] + All training steps across all curriculum stages. + """ + all_steps: list[TrainingStep] = [] + stages = ["warmup", "main", "advanced"] + + for stage in stages: + self._curriculum._stage = stage + self._curriculum._stage_history.append(stage) + logger.info("Starting curriculum stage '%s'", stage) + + skills = self._registry.list_skills() + stage_skills = self._curriculum.get_stage_skills(skills, stage) + self._current_stage_rewards = [] + + for skill in stage_skills: + sid: str = skill.id if hasattr(skill, "id") else str(skill) steps = self.optimize(sid) all_steps.extend(steps) + + avg_reward = ( + sum(self._current_stage_rewards) / len(self._current_stage_rewards) + if self._current_stage_rewards + else 0.0 + ) + logger.info( + "Curriculum stage '%s' complete: %d steps, avg_reward=%.4f", + stage, + len(self._current_stage_rewards), + avg_reward, + ) + return all_steps + def predict_reward(self, skill_id: str, action: str) -> float: + """Predict the reward for a (skill, action) pair using the learned model. + + Parameters + ---------- + skill_id : str + Target skill. + action : str + The action to evaluate. + + Returns + ------- + float + Predicted reward. + """ + skill = self._registry.get_skill(skill_id, tier=3) + if skill is None: + return 0.0 + context = self._build_context(skill_id, skill) + features = self._context_to_features(context) + return self._reward_model.predict(features, action) + + def recommend_action(self, skill_id: str) -> tuple[str, float]: + """Recommend the best action for a skill using the learned model. + + Parameters + ---------- + skill_id : str + Target skill. + + Returns + ------- + tuple[str, float] + (recommended_action, predicted_reward). + """ + skill = self._registry.get_skill(skill_id, tier=3) + if skill is None: + return "compress", 0.0 + context = self._build_context(skill_id, skill) + features = self._context_to_features(context) + return self._reward_model.predict_best_action(features) + def get_optimization_history( self, skill_id: str | None = None ) -> list[TrainingStep]: @@ -345,6 +1053,34 @@ def get_optimization_history( for row in rows ] + def get_diagnostics(self) -> dict[str, Any]: + """Return diagnostic information about all RL components. + + Returns + ------- + dict[str, Any] + Diagnostics including bandit stats, replay buffer size, + reward model weights, and curriculum state. + """ + return { + "bandit": { + "epsilon": self._bandit.epsilon, + "action_stats": self._bandit.get_stats(), + }, + "replay_buffer": { + "size": self._replay_buffer.size, + "capacity": self._replay_buffer._capacity, + }, + "reward_model": { + "update_count": self._reward_model.update_count, + "weights": self._reward_model.get_weights(), + }, + "curriculum": { + "current_stage": self._curriculum.current_stage, + "stage_history": self._curriculum.stage_history, + }, + } + def close(self) -> None: """Close the underlying database connection.""" self._conn.close() @@ -551,6 +1287,87 @@ def _evaluate_reward( ) return reward + # ------------------------------------------------------------------ + # Context / state helpers (Phase 5a) + # ------------------------------------------------------------------ + + def _build_context(self, skill_id: str, skill: Any) -> dict[str, float]: + """Build a context feature dict for the bandit. + + Features (all normalised to [0, 1]): + - ``q_value``: current Q-value + - ``success_rate``: current success rate + - ``content_length``: word count / 500 + - ``complexity``: unique-word ratio + + Parameters + ---------- + skill_id : str + Skill identifier. + skill : Any + Skill object. + + Returns + ------- + dict[str, float] + Context features. + """ + q_val = self._tracker.get_q_value(skill_id) + sr = self._tracker.get_success_rate(skill_id) + core: str = skill.tier2_core if hasattr(skill, "tier2_core") else "" + words = core.split() if core else [] + content_length = min(1.0, len(words) / 500) + unique_ratio = len(set(words)) / max(1, len(words)) + + return { + "q_value": max(0.0, min(1.0, q_val)), + "success_rate": max(0.0, min(1.0, sr)), + "content_length": content_length, + "complexity": unique_ratio, + } + + @staticmethod + def _context_to_features(context: dict[str, float]) -> list[float]: + """Convert context dict to a feature vector (sorted by key).""" + return [context[k] for k in sorted(context)] + + def _build_context_from_skill_id(self, skill_id: str) -> dict[str, float]: + """Build context for an arbitrary skill ID.""" + skill = self._registry.get_skill(skill_id, tier=3) + if skill is None: + return {"q_value": 0.5, "success_rate": 0.5, "content_length": 0.0, "complexity": 0.0} + return self._build_context(skill_id, skill) + + @staticmethod + def _hash_state(core: str, q_value: float) -> str: + """Produce a deterministic hash of skill state.""" + content = f"{core[:200]}|{q_value:.4f}" + return hashlib.sha256(content.encode()).hexdigest()[:16] + + def _state_hash_to_features(self, state_hash: str) -> list[float]: + """Convert a state hash back to features for the reward model. + + Since we can't perfectly reconstruct the state, we derive + features from the hash itself (deterministic pseudo-features). + This is a pragmatic compromise for the stdlib-only constraint. + """ + # Use hash bytes as pseudo-features + raw = bytes.fromhex(state_hash) + features: list[float] = [] + for i in range(4): + features.append(raw[i % len(raw)] / 255.0) + return features + + def _persist_experience(self, exp: Experience) -> None: + """Persist an experience to the ``rl_experiences`` table.""" + self._conn.execute( + "INSERT INTO rl_experiences " + "(state_hash, action, reward, next_state, recorded_at) " + "VALUES (?, ?, ?, ?, ?)", + (exp.state_hash, exp.action, exp.reward, exp.next_state_hash, exp.timestamp), + ) + self._conn.commit() + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ diff --git a/skillforge/advanced/skill_generator.py b/skillforge/advanced/skill_generator.py new file mode 100644 index 0000000..41da592 --- /dev/null +++ b/skillforge/advanced/skill_generator.py @@ -0,0 +1,677 @@ +""" +Zero-shot skill generation from task descriptions. + +Implements a template-driven skill generator that takes a natural-language +task description, infers structure (name, metadata, tags, core prompt), +and optionally evaluates and refines the generated skill through iterative +self-improvement. + +The generator is fully stdlib-only — no LLM calls are made. Instead it +uses heuristic NLP (regex tokenisation, TF-IDF-lite keyword extraction, +template assembly) to produce draft skills that can be registered in the +SkillRegistry and later refined by the RL optimizer. +""" + +from __future__ import annotations + +import hashlib +import logging +import re +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class GeneratedSkill: + """A skill produced by :class:`SkillGenerator`. + + Attributes + ---------- + skill_id : str + Unique identifier for the generated skill. + name : str + Short human-readable name. + tier1_metadata : str + Compact routing metadata (~30 tokens). + tier2_core : str + Core prompt / instruction set. + tier3_resources : list[str] + Auxiliary resource references. + tags : list[str] + Inferred topic tags. + confidence : float + Generator confidence in ``[0, 1]``. + rationale : str + Short explanation of why this structure was chosen. + """ + + skill_id: str + name: str + tier1_metadata: str + tier2_core: str + tier3_resources: list[str] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + confidence: float = 0.5 + rationale: str = "" + + +@dataclass +class GenerationRequest: + """Input specification for skill generation. + + Parameters + ---------- + description : str + Natural-language description of the task the skill should solve. + domain : str | None + Optional domain hint (e.g. ``"python"``, ``"devops"``). + complexity : str + ``"simple"`` | ``"moderate"`` | ``"complex"``. + max_tokens : int + Soft cap on tier2_core token count. + include_examples : bool + Whether to generate example invocations. + """ + + description: str + domain: str | None = None + complexity: str = "moderate" + max_tokens: int = 500 + include_examples: bool = True + + +# --------------------------------------------------------------------------- +# Protocols +# --------------------------------------------------------------------------- + + +class SkillRegistryProto: + """Minimal registry interface for registering generated skills.""" + + def register_skill( + self, + name: str, + tier1_metadata: str, + tier2_core: str = "", + tier3_resources: list[str] | None = None, + tags: list[str] | None = None, + skill_id: str | None = None, + ) -> Any: ... # pragma: no cover + + +# --------------------------------------------------------------------------- +# Keyword extraction (TF-IDF-lite) +# --------------------------------------------------------------------------- + +# Common English stop words — kept minimal to avoid external deps. +_STOP_WORDS: frozenset[str] = frozenset( + "a an the and or but is are was were be been being have has had do does " + "did will would shall should may might can could of in to for on with at " + "by from as into through during before after above below between out off " + "over under again further then once here there when where why how all both " + "each few more most other some such no nor not only own same so than too " + "very it its this that these those i me my we our you your he him his she " + "her they them their what which who whom".split() +) + + +def _extract_keywords(text: str, top_n: int = 8) -> list[str]: + """Extract top-N keywords from text using a TF-IDF-lite approach. + + Parameters + ---------- + text : str + Input text to extract keywords from. + top_n : int + Number of keywords to return. + + Returns + ------- + list[str] + Ranked list of keywords. + """ + tokens = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]{2,}", text.lower()) + freq: dict[str, int] = {} + for tok in tokens: + if tok not in _STOP_WORDS: + freq[tok] = freq.get(tok, 0) + 1 + # Score = frequency × length bonus (longer terms are often more specific) + scored = sorted(freq.items(), key=lambda kv: kv[1] * (1 + len(kv[0]) * 0.1), reverse=True) + return [kw for kw, _ in scored[:top_n]] + + +def _infer_tags(keywords: list[str], description: str, domain: str | None) -> list[str]: + """Infer semantic tags from keywords, description, and optional domain. + + Parameters + ---------- + keywords : list[str] + Extracted keywords. + description : str + Original description text. + domain : str | None + Optional domain hint. + + Returns + ------- + list[str] + Deduplicated tags. + """ + tags: list[str] = [] + if domain: + tags.append(domain.lower().strip()) + + # Domain heuristics from keywords + _DOMAIN_SIGNALS: dict[str, list[str]] = { + "python": ["python", "pip", "django", "flask", "pytest"], + "javascript": ["javascript", "node", "npm", "react", "typescript", "vue"], + "devops": ["docker", "kubernetes", "k8s", "ci", "cd", "terraform", "deploy"], + "database": ["sql", "postgres", "mysql", "sqlite", "query", "database"], + "security": ["auth", "oauth", "jwt", "encrypt", "hash", "security"], + "data": ["pandas", "numpy", "dataframe", "csv", "etl", "spark"], + "web": ["api", "rest", "graphql", "http", "endpoint", "webhook"], + "testing": ["test", "mock", "assert", "coverage", "fixture"], + } + kw_set = set(keywords) + desc_lower = description.lower() + for tag, signals in _DOMAIN_SIGNALS.items(): + if tag in kw_set or any(s in kw_set or s in desc_lower for s in signals): + tags.append(tag) + + # Add top 3 keywords as tags if not already present + for kw in keywords[:3]: + if kw not in tags and kw not in _STOP_WORDS: + tags.append(kw) + + # Deduplicate while preserving order + seen: set[str] = set() + unique: list[str] = [] + for t in tags: + t_clean = t.strip().lower() + if t_clean and t_clean not in seen: + seen.add(t_clean) + unique.append(t_clean) + return unique[:8] + + +# --------------------------------------------------------------------------- +# SkillGenerator +# --------------------------------------------------------------------------- + + +class SkillGenerator: + """Zero-shot skill generator. + + Creates draft skills from natural-language descriptions using + template-driven heuristic NLP — no external LLM dependency. + + Parameters + ---------- + registry : SkillRegistryProto | None + Optional registry for auto-registering generated skills. + max_core_tokens : int + Default soft cap on generated tier2_core token count. + """ + + def __init__( + self, + registry: SkillRegistryProto | None = None, + max_core_tokens: int = 500, + ) -> None: + self._registry = registry + self._max_core_tokens = max_core_tokens + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def generate(self, request: GenerationRequest) -> GeneratedSkill: + """Generate a single skill from a :class:`GenerationRequest`. + + Parameters + ---------- + request : GenerationRequest + The generation specification. + + Returns + ------- + GeneratedSkill + The generated skill ready for registration. + """ + desc = request.description.strip() + if not desc: + raise ValueError("Description must not be empty") + + keywords = _extract_keywords(desc, top_n=8) + tags = _infer_tags(keywords, desc, request.domain) + + name = self._generate_name(desc, keywords) + tier1 = self._generate_tier1(desc, keywords, tags) + tier2 = self._generate_tier2(desc, keywords, tags, request) + tier3 = self._generate_tier3(desc, keywords) + confidence = self._estimate_confidence(desc, keywords) + rationale = self._build_rationale(keywords, tags, request.complexity) + + skill = GeneratedSkill( + skill_id=f"gen_{uuid.uuid4().hex[:12]}", + name=name, + tier1_metadata=tier1, + tier2_core=tier2, + tier3_resources=tier3, + tags=tags, + confidence=confidence, + rationale=rationale, + ) + logger.info( + "Generated skill '%s' (confidence=%.2f, tags=%s)", + skill.name, + skill.confidence, + skill.tags, + ) + return skill + + def generate_and_register( + self, request: GenerationRequest + ) -> GeneratedSkill: + """Generate a skill and register it in the linked registry. + + Parameters + ---------- + request : GenerationRequest + The generation specification. + + Returns + ------- + GeneratedSkill + The generated skill (now registered). + + Raises + ------ + RuntimeError + If no registry is configured. + """ + if self._registry is None: + raise RuntimeError( + "No registry configured — use SkillGenerator(registry=…)" + ) + skill = self.generate(request) + self._registry.register_skill( + name=skill.name, + tier1_metadata=skill.tier1_metadata, + tier2_core=skill.tier2_core, + tier3_resources=skill.tier3_resources, + tags=skill.tags, + skill_id=skill.skill_id, + ) + logger.info("Registered generated skill '%s'", skill.skill_id) + return skill + + def generate_batch( + self, requests: list[GenerationRequest] + ) -> list[GeneratedSkill]: + """Generate multiple skills in one call. + + Parameters + ---------- + requests : list[GenerationRequest] + List of generation requests. + + Returns + ------- + list[GeneratedSkill] + Generated skills in the same order as requests. + """ + return [self.generate(r) for r in requests] + + def refine( + self, + skill: GeneratedSkill, + feedback: str, + ) -> GeneratedSkill: + """Refine a generated skill using textual feedback. + + The feedback is analysed for additional keywords and structural + hints, then merged into the existing skill content. + + Parameters + ---------- + skill : GeneratedSkill + The skill to refine. + feedback : str + Free-text feedback or correction. + + Returns + ------- + GeneratedSkill + A new :class:`GeneratedSkill` with updated content. + """ + fb_keywords = _extract_keywords(feedback, top_n=5) + existing_kw = set(_extract_keywords(skill.tier2_core, top_n=8)) + + # Merge new keywords into tags + existing_tag_set = set(skill.tags) + new_tags = list(skill.tags) + for kw in fb_keywords: + if kw not in existing_tag_set and kw not in _STOP_WORDS: + new_tags.append(kw) + existing_tag_set.add(kw) + new_tags = new_tags[:8] + + # Append feedback-derived guidance to core + guidance_lines = self._feedback_to_guidance(feedback, fb_keywords) + updated_core = skill.tier2_core + if guidance_lines: + updated_core += "\n\n## Refined guidance\n" + guidance_lines + + # Clamp token count + max_tok = self._max_core_tokens + updated_core = self._truncate_to_tokens(updated_core, max_tok) + + # Boost confidence slightly if feedback is positive + positive_signals = {"good", "great", "correct", "perfect", "yes", "right"} + boost = 0.05 if any(w in feedback.lower() for w in positive_signals) else 0.0 + + return GeneratedSkill( + skill_id=skill.skill_id, + name=skill.name, + tier1_metadata=skill.tier1_metadata, + tier2_core=updated_core, + tier3_resources=skill.tier3_resources, + tags=new_tags, + confidence=min(1.0, skill.confidence + boost), + rationale=skill.rationale + f"\nRefined with: {feedback[:120]}", + ) + + # ------------------------------------------------------------------ + # Internal generation helpers + # ------------------------------------------------------------------ + + def _generate_name(self, desc: str, keywords: list[str]) -> str: + """Generate a short skill name from the description and keywords. + + Takes the first 5 words of the description (capped at 60 chars), + capitalised title-style. + """ + words = desc.split()[:5] + name = " ".join(words).strip(".,;:!?") + # Capitalise first letter of each word + name = " ".join(w.capitalize() for w in name.split()) + if len(name) > 60: + name = name[:57] + "..." + return name + + def _generate_tier1( + self, desc: str, keywords: list[str], tags: list[str] + ) -> str: + """Generate ~30-token tier1 routing metadata. + + Combines a condensed description with top tags. + """ + # Take first sentence + first_sentence = re.split(r"[.!?]", desc)[0].strip() + if len(first_sentence) > 120: + first_sentence = first_sentence[:117] + "..." + tag_str = " ".join(f"#{t}" for t in tags[:4]) + tier1 = f"{first_sentence} | {tag_str}" if tag_str else first_sentence + # Clamp to ~30 tokens + return self._truncate_to_tokens(tier1, 30) + + def _generate_tier2( + self, + desc: str, + keywords: list[str], + tags: list[str], + request: GenerationRequest, + ) -> str: + """Generate the core prompt / instruction set (tier2). + + Assembles a structured prompt from the description, keywords, + and complexity level. + """ + sections: list[str] = [] + + # Header + sections.append(f"# {self._generate_name(desc, keywords)}") + sections.append("") + + # Objective + sections.append("## Objective") + sections.append(desc) + sections.append("") + + # Steps (complexity-dependent) + steps = self._generate_steps(desc, keywords, request.complexity) + sections.append("## Steps") + for idx, step in enumerate(steps, start=1): + sections.append(f"{idx}. {step}") + sections.append("") + + # Constraints + constraints = self._generate_constraints(request.complexity) + sections.append("## Constraints") + for c in constraints: + sections.append(f"- {c}") + sections.append("") + + # Examples + if request.include_examples: + sections.append("## Example") + sections.append(self._generate_example(desc, keywords)) + sections.append("") + + # Output format + sections.append("## Output format") + sections.append(self._infer_output_format(desc, keywords)) + + core = "\n".join(sections) + return self._truncate_to_tokens(core, request.max_tokens) + + def _generate_tier3(self, desc: str, keywords: list[str]) -> list[str]: + """Generate placeholder tier3 resource references.""" + resources: list[str] = [] + # Infer a possible reference document + if "api" in keywords or "endpoint" in keywords: + resources.append("api_reference.md") + if "test" in keywords or "testing" in keywords: + resources.append("test_examples.py") + if len(keywords) > 3: + resources.append(f"glossary_{'_'.join(keywords[:3])}.md") + return resources + + # ------------------------------------------------------------------ + # Step / constraint inference + # ------------------------------------------------------------------ + + def _generate_steps( + self, desc: str, keywords: list[str], complexity: str + ) -> list[str]: + """Infer procedural steps from the description. + + Heuristic: parse imperative verbs and action phrases. + """ + step_count = {"simple": 3, "moderate": 5, "complex": 8}.get(complexity, 5) + + # If the description already contains numbered steps, extract them + numbered = re.findall(r"\d+[.)]\s*(.+?)(?=\d+[.)]|\Z)", desc, re.DOTALL) + if numbered: + return [s.strip().rstrip(". ") for s in numbered[:step_count]] + + # Otherwise, generate generic procedural steps from keywords + action_verbs = [ + "Analyse", + "Identify", + "Implement", + "Validate", + "Refine", + "Document", + "Test", + "Deploy", + "Review", + "Optimise", + ] + steps: list[str] = [] + for i in range(step_count): + verb = action_verbs[i % len(action_verbs)] + if i < len(keywords): + steps.append(f"{verb} the {keywords[i]} component") + else: + steps.append(f"{verb} the solution for correctness") + return steps + + def _generate_constraints(self, complexity: str) -> list[str]: + """Generate complexity-appropriate constraints.""" + base = [ + "Use only standard library modules (no external dependencies)", + "Include full type hints and docstrings", + "Handle edge cases gracefully with informative error messages", + ] + if complexity in ("moderate", "complex"): + base.extend([ + "Ensure idempotency — repeated runs should produce identical results", + "Log key decisions at DEBUG level", + ]) + if complexity == "complex": + base.extend([ + "Design for extensibility — use Protocol-based interfaces", + "Include performance considerations for large inputs", + ]) + return base + + def _generate_example(self, desc: str, keywords: list[str]) -> str: + """Generate a brief usage example.""" + kw_sample = keywords[0] if keywords else "input" + return ( + f"Given the input related to '{kw_sample}', " + f"the expected outcome is a well-structured result that " + f"satisfies the described task requirements." + ) + + def _infer_output_format(self, desc: str, keywords: list[str]) -> str: + """Infer likely output format from the description.""" + desc_lower = desc.lower() + if "json" in desc_lower: + return "Return a JSON object with the computed results." + if "table" in desc_lower or "csv" in desc_lower: + return "Return data in tabular format (list of dictionaries)." + if "list" in desc_lower: + return "Return an ordered list of results." + if "report" in desc_lower: + return "Return a structured text report with sections." + return "Return a clear, structured result with appropriate formatting." + + # ------------------------------------------------------------------ + # Rationale builder + # ------------------------------------------------------------------ + + @staticmethod + def _build_rationale( + keywords: list[str], tags: list[str], complexity: str + ) -> str: + """Build a human-readable rationale for the generation decision. + + Parameters + ---------- + keywords : list[str] + Extracted keywords. + tags : list[str] + Inferred tags. + complexity : str + Requested complexity level. + + Returns + ------- + str + Short rationale string. + """ + parts: list[str] = [ + f"Extracted {len(keywords)} keywords: {', '.join(keywords[:5])}", + f"Inferred {len(tags)} tags: {', '.join(tags[:4])}", + f"Complexity: {complexity}", + ] + return ". ".join(parts) + "." + + # ------------------------------------------------------------------ + # Confidence estimation + # ------------------------------------------------------------------ + + def _estimate_confidence(self, desc: str, keywords: list[str]) -> float: + """Estimate generation confidence in ``[0.2, 0.95]``. + + Factors: description length, keyword count, structural signals. + """ + score = 0.4 # baseline + + # Length bonus (longer descriptions provide more signal) + word_count = len(desc.split()) + if word_count > 20: + score += 0.1 + if word_count > 50: + score += 0.1 + + # Keyword richness + if len(keywords) >= 5: + score += 0.1 + if len(keywords) >= 8: + score += 0.05 + + # Structural signals (numbered lists, headings) + if re.search(r"\d+[.)]", desc): + score += 0.1 + if re.search(r"^#+\s", desc, re.MULTILINE): + score += 0.1 + + # Code blocks suggest well-defined task + if "```" in desc: + score += 0.05 + + return max(0.2, min(0.95, score)) + + # ------------------------------------------------------------------ + # Refinement helpers + # ------------------------------------------------------------------ + + def _feedback_to_guidance( + self, feedback: str, keywords: list[str] + ) -> str: + """Convert feedback text into actionable guidance lines.""" + lines: list[str] = [] + sentences = re.split(r"[.!?]", feedback) + for sent in sentences: + sent = sent.strip() + if len(sent) > 10: + lines.append(f"- {sent}") + return "\n".join(lines[:5]) + + # ------------------------------------------------------------------ + # Token estimation + # ------------------------------------------------------------------ + + @staticmethod + def _estimate_tokens(text: str) -> int: + """Estimate token count using the word×4/3 heuristic.""" + if not text: + return 0 + return int(len(text.split()) * 4 / 3) + + @staticmethod + def _truncate_to_tokens(text: str, max_tokens: int) -> str: + """Truncate text to approximately *max_tokens* tokens. + + Uses the word×4/3 heuristic for estimation. + """ + if not text: + return text + words = text.split() + estimated = int(len(words) * 4 / 3) + if estimated <= max_tokens: + return text + target_words = int(max_tokens * 3 / 4) + return " ".join(words[:target_words]) diff --git a/skillforge/async_support/__init__.py b/skillforge/async_support/__init__.py new file mode 100644 index 0000000..9d11c2f --- /dev/null +++ b/skillforge/async_support/__init__.py @@ -0,0 +1,15 @@ +"""SkillForge Async Support — async wrappers for core operations. + +Provides asyncio-based wrappers around the registry, tracker, and loader +for non-blocking skill operations in async applications. +""" + +from skillforge.async_support.async_registry import AsyncSkillRegistry +from skillforge.async_support.async_tracker import AsyncQValueTracker +from skillforge.async_support.async_loader import AsyncProgressiveLoader + +__all__ = [ + "AsyncSkillRegistry", + "AsyncQValueTracker", + "AsyncProgressiveLoader", +] diff --git a/skillforge/async_support/async_loader.py b/skillforge/async_support/async_loader.py new file mode 100644 index 0000000..4a22c03 --- /dev/null +++ b/skillforge/async_support/async_loader.py @@ -0,0 +1,101 @@ +"""Async wrapper for ProgressiveLoader. + +Provides non-blocking async versions of :class:`ProgressiveLoader` +methods by delegating synchronous calls to a thread pool via +:func:`asyncio.to_thread`. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from ..core.loader import ProgressiveLoader +from ..core.registry import Skill + + +class AsyncProgressiveLoader: + """Async-friendly wrapper around :class:`ProgressiveLoader`. + + All synchronous operations are executed in a thread pool + via :func:`asyncio.to_thread`. + + Parameters + ---------- + registry : AsyncSkillRegistry + Async wrapper around the skill registry. + tracker : AsyncQValueTracker + Async wrapper around the outcome tracker. + """ + + def __init__( + self, + registry: Any, + tracker: Any, + ) -> None: + self._sync = ProgressiveLoader(registry.sync, tracker.sync) + + @property + def sync(self) -> ProgressiveLoader: + """Access the underlying synchronous loader. + + Returns + ------- + ProgressiveLoader + The wrapped loader instance. + """ + return self._sync + + # ------------------------------------------------------------------ + # Loading + # ------------------------------------------------------------------ + + async def load_skill( + self, + query: str, + tier: int = 1, + routing: str = "q_value", + limit: int = 5, + ) -> list[Skill]: + """Load skills matching a query (async). + + Parameters + ---------- + query : str + Natural-language query or keywords. + tier : int + Maximum detail level (1–3). + routing : str + Ranking strategy. + limit : int + Maximum skills to return. + + Returns + ------- + list[Skill] + Ranked and trimmed list of skills. + """ + return await asyncio.to_thread( + self._sync.load_skill, + query, + tier=tier, + routing=routing, + limit=limit, + ) + + async def get_sticky_skills(self, limit: int = 5) -> list[Skill]: + """Return the top skills to keep pre-loaded (async). + + Parameters + ---------- + limit : int + Maximum skills to return. + + Returns + ------- + list[Skill] + Top skills ranked by a composite score. + """ + return await asyncio.to_thread( + self._sync.get_sticky_skills, limit=limit + ) diff --git a/skillforge/async_support/async_registry.py b/skillforge/async_support/async_registry.py new file mode 100644 index 0000000..138c2cf --- /dev/null +++ b/skillforge/async_support/async_registry.py @@ -0,0 +1,206 @@ +"""Async wrapper for SkillRegistry. + +Provides non-blocking async versions of all :class:`SkillRegistry` +methods by delegating synchronous calls to a thread pool via +:func:`asyncio.to_thread`. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +from ..core.registry import Skill, SkillLifecycle, SkillRegistry + + +class AsyncSkillRegistry: + """Async-friendly wrapper around :class:`SkillRegistry`. + + All synchronous database operations are executed in a thread pool + via :func:`asyncio.to_thread` so they don't block the event loop. + + Parameters + ---------- + db_path : str | Path | None + Path to the SQLite database file. + Defaults to ``~/.skillforge/skills.db``. + """ + + def __init__(self, db_path: str | Path | None = None) -> None: + self._sync = SkillRegistry(db_path=db_path) + + @property + def sync(self) -> SkillRegistry: + """Access the underlying synchronous registry. + + Returns + ------- + SkillRegistry + The wrapped registry instance. + """ + return self._sync + + # ------------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------------ + + async def register_skill( + self, + name: str, + tier1_metadata: str, + tier2_core: str = "", + tier3_resources: list[str] | None = None, + tags: list[str] | None = None, + skill_id: str | None = None, + ) -> Skill: + """Register a new skill (async). + + Parameters + ---------- + name : str + Skill name. + tier1_metadata : str + Short routing summary. + tier2_core : str + Core prompt / instructions. + tier3_resources : list[str] | None + Auxiliary resource list. + tags : list[str] | None + Tags for filtering. + skill_id : str | None + Explicit ID. UUID is generated when *None*. + + Returns + ------- + Skill + The newly registered skill. + """ + return await asyncio.to_thread( + self._sync.register_skill, + name=name, + tier1_metadata=tier1_metadata, + tier2_core=tier2_core, + tier3_resources=tier3_resources, + tags=tags, + skill_id=skill_id, + ) + + async def get_skill( + self, skill_id: str, tier: int = 1 + ) -> Skill | None: + """Retrieve a skill by ID (async). + + Parameters + ---------- + skill_id : str + Skill identifier. + tier : int + Detail level (1–3). + + Returns + ------- + Skill | None + The skill, or *None* if not found. + """ + return await asyncio.to_thread( + self._sync.get_skill, skill_id, tier=tier + ) + + async def update_skill( + self, skill_id: str, updates: dict[str, Any] + ) -> Skill | None: + """Apply partial updates to a skill (async). + + Parameters + ---------- + skill_id : str + Skill to update. + updates : dict[str, Any] + Fields to change. + + Returns + ------- + Skill | None + Updated skill, or *None* if not found. + """ + return await asyncio.to_thread( + self._sync.update_skill, skill_id, updates + ) + + async def list_skills( + self, + lifecycle: SkillLifecycle | None = None, + tags: list[str] | None = None, + limit: int = 100, + offset: int = 0, + ) -> list[Skill]: + """List skills with optional filters (async). + + Parameters + ---------- + lifecycle : SkillLifecycle | None + Filter by lifecycle stage. + tags : list[str] | None + Filter by tags (OR logic). + limit : int + Maximum results. + offset : int + Pagination offset. + + Returns + ------- + list[Skill] + Matching skills sorted by Q-value. + """ + return await asyncio.to_thread( + self._sync.list_skills, + lifecycle=lifecycle, + tags=tags, + limit=limit, + offset=offset, + ) + + async def search_skills( + self, query: str, limit: int = 20 + ) -> list[Skill]: + """Keyword search (async). + + Parameters + ---------- + query : str + Search string. + limit : int + Maximum results. + + Returns + ------- + list[Skill] + Matching skills. + """ + return await asyncio.to_thread( + self._sync.search_skills, query, limit=limit + ) + + async def version_skill(self, skill_id: str) -> Skill | None: + """Bump the version number of a skill (async). + + Parameters + ---------- + skill_id : str + Skill to version. + + Returns + ------- + Skill | None + Updated skill, or *None* if not found. + """ + return await asyncio.to_thread(self._sync.version_skill, skill_id) + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + async def close(self) -> None: + """Close the database connection (async).""" + await asyncio.to_thread(self._sync.close) diff --git a/skillforge/async_support/async_tracker.py b/skillforge/async_support/async_tracker.py new file mode 100644 index 0000000..e43f945 --- /dev/null +++ b/skillforge/async_support/async_tracker.py @@ -0,0 +1,194 @@ +"""Async wrapper for QValueTracker. + +Provides non-blocking async versions of all :class:`QValueTracker` +methods by delegating synchronous calls to a thread pool via +:func:`asyncio.to_thread`. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +from ..core.tracker import Outcome, QValueTracker + + +class AsyncQValueTracker: + """Async-friendly wrapper around :class:`QValueTracker`. + + All synchronous database operations are executed in a thread pool + via :func:`asyncio.to_thread` so they don't block the event loop. + + Parameters + ---------- + db_path : str | Path | None + Path to the SQLite database file. + Defaults to ``~/.skillforge/tracker.db``. + """ + + def __init__(self, db_path: str | Path | None = None) -> None: + self._sync = QValueTracker(db_path=db_path) + + @property + def sync(self) -> QValueTracker: + """Access the underlying synchronous tracker. + + Returns + ------- + QValueTracker + The wrapped tracker instance. + """ + return self._sync + + # ------------------------------------------------------------------ + # Recording + # ------------------------------------------------------------------ + + async def record_outcome(self, outcome: Outcome) -> None: + """Persist a single execution outcome (async). + + Parameters + ---------- + outcome : Outcome + The outcome to record. + """ + await asyncio.to_thread(self._sync.record_outcome, outcome) + + # ------------------------------------------------------------------ + # Queries + # ------------------------------------------------------------------ + + async def get_q_value(self, skill_id: str) -> float: + """Return current Q-value for a skill (async). + + Parameters + ---------- + skill_id : str + Skill to query. + + Returns + ------- + float + Current Q-value (0.5 if unknown). + """ + return await asyncio.to_thread(self._sync.get_q_value, skill_id) + + async def get_success_rate(self, skill_id: str) -> float: + """Return rolling success rate for a skill (async). + + Parameters + ---------- + skill_id : str + Skill to query. + + Returns + ------- + float + Success rate (0.0–1.0). + """ + return await asyncio.to_thread( + self._sync.get_success_rate, skill_id + ) + + async def get_stats(self, skill_id: str) -> dict[str, Any]: + """Return aggregate statistics for a skill (async). + + Parameters + ---------- + skill_id : str + Skill to query. + + Returns + ------- + dict[str, Any] + Statistics dictionary. + """ + return await asyncio.to_thread(self._sync.get_stats, skill_id) + + async def get_failures( + self, skill_id: str, limit: int = 10 + ) -> list[dict[str, Any]]: + """Retrieve recent failed outcomes (async). + + Parameters + ---------- + skill_id : str + Skill to query. + limit : int + Maximum failures to return. + + Returns + ------- + list[dict[str, Any]] + List of failure dictionaries. + """ + return await asyncio.to_thread( + self._sync.get_failures, skill_id, limit=limit + ) + + async def get_usage_count(self, skill_id: str) -> int: + """Return total number of recorded outcomes (async). + + Parameters + ---------- + skill_id : str + Skill to query. + + Returns + ------- + int + Total outcome count. + """ + return await asyncio.to_thread( + self._sync.get_usage_count, skill_id + ) + + # ------------------------------------------------------------------ + # TD(λ) Update + # ------------------------------------------------------------------ + + async def td_lambda_update( + self, + skill_id: str, + reward: float, + alpha: float = 0.1, + gamma: float = 0.9, + lambda_: float = 0.8, + ) -> float: + """Apply a TD(λ) update (async). + + Parameters + ---------- + skill_id : str + Skill to update. + reward : float + Observed reward. + alpha : float + Learning rate. + gamma : float + Discount factor. + lambda_ : float + Trace decay. + + Returns + ------- + float + The new Q-value. + """ + return await asyncio.to_thread( + self._sync.td_lambda_update, + skill_id, + reward, + alpha=alpha, + gamma=gamma, + lambda_=lambda_, + ) + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + async def close(self) -> None: + """Close the database connection (async).""" + await asyncio.to_thread(self._sync.close) diff --git a/skillforge/core/__init__.py b/skillforge/core/__init__.py index df11964..fa6bdbf 100644 --- a/skillforge/core/__init__.py +++ b/skillforge/core/__init__.py @@ -1 +1,64 @@ -"""SkillForge Core - graph, diagnosis, and evolution modules.""" +"""SkillForge Core - graph, diagnosis, evolution, database, resilience, and caching modules.""" + +from skillforge.core.db import ( + DatabaseBackend, + DatabaseConnection, + SQLiteBackend, + MemoryBackend, + QueryResult, + TableSchema, + ColumnDef, + create_backend, +) + +from skillforge.core.resilience import ( + CircuitBreaker, + CircuitState, + CircuitStats, + CircuitOpenError, + RetryPolicy, + Bulkhead, + BulkheadStats, + GracefulDegradation, + ResilientExecutor, + ExecutorStats, +) + +from skillforge.core.cache import ( + TTLCache, + LRUCache, + CacheStats, + CachedStore, + cached, + cache_key, +) + +__all__ = [ + # Database + "DatabaseBackend", + "DatabaseConnection", + "SQLiteBackend", + "MemoryBackend", + "QueryResult", + "TableSchema", + "ColumnDef", + "create_backend", + # Resilience + "CircuitBreaker", + "CircuitState", + "CircuitStats", + "CircuitOpenError", + "RetryPolicy", + "Bulkhead", + "BulkheadStats", + "GracefulDegradation", + "ResilientExecutor", + "ExecutorStats", + # Caching + "TTLCache", + "LRUCache", + "CacheStats", + "CachedStore", + "cached", + "cache_key", +] diff --git a/skillforge/core/cache.py b/skillforge/core/cache.py new file mode 100644 index 0000000..ca6b74d --- /dev/null +++ b/skillforge/core/cache.py @@ -0,0 +1,313 @@ +"""Performance Caching Module. + +Provides production-grade caching for SkillForge operations: + +- :class:`TTLCache` — time-to-live cache with automatic expiry. +- :class:`LRUCache` — Least Recently Used eviction cache. +- :class:`CacheStats` — hit/miss tracking for cache monitoring. +- :class:`CachedStore` — composable cache layer over any data source. +- :func:`cached` — function decorator for automatic result caching. + +All implementations are stdlib-only, thread-safe, and zero-dependency. +""" + +from __future__ import annotations + +import hashlib +import json +import threading +import time +from collections import OrderedDict +from dataclasses import dataclass, field +from datetime import datetime, timezone +from functools import wraps +from typing import Any, Callable, Generic, Optional, TypeVar + +T = TypeVar("T") + +logger = __import__("logging").getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Cache statistics +# --------------------------------------------------------------------------- + +@dataclass +class CacheStats: + """Cache performance counters.""" + hits: int = 0 + misses: int = 0 + evictions: int = 0 + inserts: int = 0 + size: int = 0 + + @property + def hit_rate(self) -> float: + total = self.hits + self.misses + return self.hits / total if total > 0 else 0.0 + + @property + def total_requests(self) -> int: + return self.hits + self.misses + + +# --------------------------------------------------------------------------- +# TTL Cache +# --------------------------------------------------------------------------- + +class TTLCache(Generic[T]): + """Thread-safe Time-To-Live cache. + + Entries expire after ``ttl_seconds`` and are lazily evicted on access. + + Parameters + ---------- + ttl_seconds : float + Default time-to-live in seconds. + max_size : int + Maximum number of entries (0 = unlimited). + """ + + def __init__(self, ttl_seconds: float = 300.0, max_size: int = 1024) -> None: + self._ttl = ttl_seconds + self._max_size = max_size + self._store: dict[str, tuple[float, T]] = {} + self._stats = CacheStats() + self._lock = threading.Lock() + + def get(self, key: str) -> T | None: + """Return cached value or ``None`` if expired/missing.""" + with self._lock: + entry = self._store.get(key) + if entry is None: + self._stats.misses += 1 + return None + ts, value = entry + if time.monotonic() - ts > self._ttl: + del self._store[key] + self._stats.misses += 1 + self._stats.evictions += 1 + return None + self._stats.hits += 1 + return value + + def put(self, key: str, value: T) -> None: + """Store a value with the default TTL.""" + with self._lock: + if self._max_size > 0 and len(self._store) >= self._max_size and key not in self._store: + self._evict_expired() + if self._max_size > 0 and len(self._store) >= self._max_size: + self._evict_oldest() + self._store[key] = (time.monotonic(), value) + self._stats.inserts += 1 + self._stats.size = len(self._store) + + def invalidate(self, key: str) -> bool: + """Remove a specific key. Returns ``True`` if key existed.""" + with self._lock: + if key in self._store: + del self._store[key] + self._stats.size = len(self._store) + return True + return False + + def clear(self) -> None: + """Remove all entries.""" + with self._lock: + self._store.clear() + self._stats.size = 0 + + @property + def stats(self) -> CacheStats: + with self._lock: + self._stats.size = len(self._store) + return CacheStats( + hits=self._stats.hits, + misses=self._stats.misses, + evictions=self._stats.evictions, + inserts=self._stats.inserts, + size=self._stats.size, + ) + + def _evict_expired(self) -> None: + now = time.monotonic() + expired = [k for k, (ts, _) in self._store.items() if now - ts > self._ttl] + for k in expired: + del self._store[k] + self._stats.evictions += 1 + + def _evict_oldest(self) -> None: + if self._store: + oldest_key = min(self._store, key=lambda k: self._store[k][0]) + del self._store[oldest_key] + self._stats.evictions += 1 + + +# --------------------------------------------------------------------------- +# LRU Cache +# --------------------------------------------------------------------------- + +class LRUCache(Generic[T]): + """Thread-safe Least Recently Used cache. + + Parameters + ---------- + max_size : int + Maximum entries before eviction. + """ + + def __init__(self, max_size: int = 256) -> None: + self._max_size = max_size + self._store: OrderedDict[str, T] = OrderedDict() + self._stats = CacheStats() + self._lock = threading.Lock() + + def get(self, key: str) -> T | None: + with self._lock: + if key not in self._store: + self._stats.misses += 1 + return None + self._store.move_to_end(key) + self._stats.hits += 1 + return self._store[key] + + def put(self, key: str, value: T) -> None: + with self._lock: + if key in self._store: + self._store.move_to_end(key) + elif len(self._store) >= self._max_size: + self._store.popitem(last=False) + self._stats.evictions += 1 + self._store[key] = value + self._stats.inserts += 1 + self._stats.size = len(self._store) + + def invalidate(self, key: str) -> bool: + with self._lock: + if key in self._store: + del self._store[key] + self._stats.size = len(self._store) + return True + return False + + def clear(self) -> None: + with self._lock: + self._store.clear() + self._stats.size = 0 + + @property + def stats(self) -> CacheStats: + with self._lock: + self._stats.size = len(self._store) + return CacheStats( + hits=self._stats.hits, + misses=self._stats.misses, + evictions=self._stats.evictions, + inserts=self._stats.inserts, + size=self._stats.size, + ) + + +# --------------------------------------------------------------------------- +# Cached Store — composable cache layer +# --------------------------------------------------------------------------- + +class CachedStore(Generic[T]): + """Wraps a data source with a TTL cache. + + Parameters + ---------- + fetch_fn : callable + Function that fetches data given a key. + ttl_seconds : float + Cache TTL in seconds. + max_size : int + Maximum cache entries. + """ + + def __init__( + self, + fetch_fn: Callable[[str], T], + ttl_seconds: float = 300.0, + max_size: int = 1024, + ) -> None: + self._fetch = fetch_fn + self._cache: TTLCache[T] = TTLCache(ttl_seconds, max_size) + + def get(self, key: str) -> T: + """Fetch from cache, falling back to source on miss.""" + cached = self._cache.get(key) + if cached is not None: + return cached + value = self._fetch(key) + self._cache.put(key, value) + return value + + def invalidate(self, key: str) -> bool: + return self._cache.invalidate(key) + + def clear(self) -> None: + self._cache.clear() + + @property + def stats(self) -> CacheStats: + return self._cache.stats + + +# --------------------------------------------------------------------------- +# Cache key helpers +# --------------------------------------------------------------------------- + +def cache_key(*args: Any, **kwargs: Any) -> str: + """Generate a deterministic cache key from arguments.""" + key_data = json.dumps({"args": args, "kwargs": kwargs}, sort_keys=True, default=str) + return hashlib.md5(key_data.encode()).hexdigest() + + +# --------------------------------------------------------------------------- +# Function decorator +# --------------------------------------------------------------------------- + +def cached( + ttl_seconds: float = 300.0, + max_size: int = 256, + key_fn: Callable[..., str] | None = None, +) -> Callable: + """Decorator that caches function results with TTL. + + Parameters + ---------- + ttl_seconds : float + Cache TTL in seconds. + max_size : int + Maximum entries. + key_fn : callable, optional + Custom key generation function. Defaults to ``cache_key``. + + Example:: + + @cached(ttl_seconds=60) + def get_skill_stats(skill_id: str) -> dict: + # expensive query + return result + """ + _cache: TTLCache = TTLCache(ttl_seconds, max_size) + _key_fn = key_fn or cache_key + + def decorator(fn: Callable) -> Callable: + @wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + key = _key_fn(*args, **kwargs) + result = _cache.get(key) + if result is not None: + return result + value = fn(*args, **kwargs) + _cache.put(key, value) + return value + + wrapper.cache = _cache # type: ignore[attr-defined] + wrapper.cache_clear = _cache.clear # type: ignore[attr-defined] + wrapper.cache_stats = lambda: _cache.stats # type: ignore[attr-defined] + return wrapper + + return decorator diff --git a/skillforge/core/db.py b/skillforge/core/db.py new file mode 100644 index 0000000..cce3e6e --- /dev/null +++ b/skillforge/core/db.py @@ -0,0 +1,750 @@ +"""Database abstraction layer for SkillForge. + +Provides a backend-agnostic storage interface so that the rest of the +SkillForge codebase does not depend on any single database engine. Two +concrete backends ship with this module: + +- :class:`SQLiteBackend` — persistent storage via ``sqlite3`` (stdlib). +- :class:`MemoryBackend` — ephemeral in-memory storage for tests and + transient usage. + +All backends implement the :class:`DatabaseBackend` protocol, making it +straightforward to add new storage engines (e.g. PostgreSQL, TinyDB) by +implementing the same interface. + +Design goals: + +- **Stdlib only** — no third-party dependencies. +- **Full type hints** — every public method is annotated. +- **Context manager support** — ``with DatabaseConnection(...) as conn:``. +- **Thread-safe** — SQLite backend uses WAL mode and connection pooling + is left to the caller. + +No external dependencies — stdlib only. +""" + +from __future__ import annotations + +import json +import logging +import sqlite3 +import threading +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Protocol, runtime_checkable + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +@dataclass +class QueryResult: + """Container for query results, analogous to a cursor's fetchall(). + + Attributes + ---------- + rows : list[dict[str, Any]] + List of row dicts (column name → value). + rowcount : int + Number of affected rows for write operations. + lastrowid : int | None + The last inserted row ID, if applicable. + """ + + rows: list[dict[str, Any]] = field(default_factory=list) + rowcount: int = 0 + lastrowid: int | None = None + + @property + def is_empty(self) -> bool: + """Whether the result set is empty.""" + return len(self.rows) == 0 + + def first(self) -> dict[str, Any] | None: + """Return the first row or ``None``.""" + return self.rows[0] if self.rows else None + + def scalar(self, column: str, default: Any = None) -> Any: + """Return the value of *column* from the first row, or *default*.""" + row = self.first() + if row is None: + return default + return row.get(column, default) + + def __len__(self) -> int: + return len(self.rows) + + def __iter__(self): + return iter(self.rows) + + def __bool__(self) -> bool: + return not self.is_empty + + +@dataclass +class ColumnDef: + """Schema column definition. + + Attributes + ---------- + name : str + Column name. + col_type : str + SQL type string (e.g. ``TEXT``, ``INTEGER``, ``REAL``). + nullable : bool + Whether NULL is allowed. + primary_key : bool + Whether this column is the primary key. + default : Any + Default value (as a Python literal, serialised to SQL). + """ + + name: str + col_type: str = "TEXT" + nullable: bool = True + primary_key: bool = False + default: Any = None + + +@dataclass +class TableSchema: + """Schema definition for a table. + + Attributes + ---------- + name : str + Table name. + columns : list[ColumnDef] + Ordered column definitions. + """ + + name: str + columns: list[ColumnDef] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Protocol (abstract interface) +# --------------------------------------------------------------------------- + + +@runtime_checkable +class DatabaseBackend(Protocol): + """Abstract database backend protocol. + + Any object that satisfies this protocol can be used as a SkillForge + storage backend. Methods follow a simplified SQL-ish API. + """ + + def create_table(self, schema: TableSchema) -> None: + """Create a table if it does not exist.""" + ... + + def drop_table(self, name: str) -> None: + """Drop a table if it exists.""" + ... + + def table_exists(self, name: str) -> bool: + """Check whether a table exists.""" + ... + + def insert(self, table: str, data: dict[str, Any]) -> QueryResult: + """Insert a single row. Returns the result with ``lastrowid``.""" + ... + + def update( + self, + table: str, + data: dict[str, Any], + where: dict[str, Any] | None = None, + ) -> QueryResult: + """Update rows matching *where*. If *where* is ``None``, updates all.""" + ... + + def delete( + self, table: str, where: dict[str, Any] | None = None + ) -> QueryResult: + """Delete rows matching *where*. If ``None``, deletes all.""" + ... + + def select( + self, + table: str, + columns: list[str] | None = None, + where: dict[str, Any] | None = None, + order_by: str | None = None, + limit: int | None = None, + offset: int = 0, + ) -> QueryResult: + """Select rows from a table.""" + ... + + def execute(self, sql: str, params: dict[str, Any] | None = None) -> QueryResult: + """Execute raw SQL (backend-specific).""" + ... + + def begin(self) -> None: + """Begin a transaction.""" + ... + + def commit(self) -> None: + """Commit the current transaction.""" + ... + + def rollback(self) -> None: + """Roll back the current transaction.""" + ... + + def close(self) -> None: + """Close the backend / release resources.""" + ... + + +# --------------------------------------------------------------------------- +# SQLite backend +# --------------------------------------------------------------------------- + + +class SQLiteBackend: + """Persistent SQLite backend. + + Parameters + ---------- + db_path : str | Path + Path to the SQLite database file. + wal_mode : bool + Enable WAL journal mode (recommended for concurrent reads). + foreign_keys : bool + Enforce foreign key constraints. + """ + + def __init__( + self, + db_path: str | Path, + wal_mode: bool = True, + foreign_keys: bool = True, + ) -> None: + self._db_path = str(db_path) + Path(self._db_path).parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(self._db_path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + if wal_mode: + self._conn.execute("PRAGMA journal_mode=WAL") + if foreign_keys: + self._conn.execute("PRAGMA foreign_keys=ON") + self._lock = threading.Lock() + + @property + def path(self) -> str: + """Return the database file path.""" + return self._db_path + + # ------------------------------------------------------------------ + # Schema + # ------------------------------------------------------------------ + + def create_table(self, schema: TableSchema) -> None: + """Create a table from a :class:`TableSchema` definition.""" + col_defs: list[str] = [] + for col in schema.columns: + parts = [col.name, col.col_type] + if col.primary_key: + parts.append("PRIMARY KEY") + if not col.nullable and not col.primary_key: + parts.append("NOT NULL") + if col.default is not None: + if isinstance(col.default, str): + parts.append(f"DEFAULT '{col.default}'") + else: + parts.append(f"DEFAULT {col.default}") + col_defs.append(" ".join(parts)) + + sql = ( + f"CREATE TABLE IF NOT EXISTS {schema.name} " + f"({', '.join(col_defs)})" + ) + with self._lock: + self._conn.execute(sql) + self._conn.commit() + + def drop_table(self, name: str) -> None: + """Drop a table if it exists.""" + with self._lock: + self._conn.execute(f"DROP TABLE IF EXISTS {name}") + self._conn.commit() + + def table_exists(self, name: str) -> bool: + """Check whether a table exists in the database.""" + row = self._conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (name,), + ).fetchone() + return row is not None + + # ------------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------------ + + def insert(self, table: str, data: dict[str, Any]) -> QueryResult: + """Insert a single row and return the result.""" + columns = list(data.keys()) + placeholders = [f":{c}" for c in columns] + sql = ( + f"INSERT INTO {table} ({', '.join(columns)}) " + f"VALUES ({', '.join(placeholders)})" + ) + with self._lock: + cur = self._conn.execute(sql, data) + self._conn.commit() + return QueryResult( + rowcount=cur.rowcount, + lastrowid=cur.lastrowid, + ) + + def update( + self, + table: str, + data: dict[str, Any], + where: dict[str, Any] | None = None, + ) -> QueryResult: + """Update rows matching the *where* clause.""" + set_parts = [f"{k} = :{k}" for k in data] + sql = f"UPDATE {table} SET {', '.join(set_parts)}" + params: dict[str, Any] = dict(data) + + if where: + where_parts: list[str] = [] + for i, (k, v) in enumerate(where.items()): + param_name = f"_w{i}" + where_parts.append(f"{k} = :{param_name}") + params[param_name] = v + sql += " WHERE " + " AND ".join(where_parts) + + with self._lock: + cur = self._conn.execute(sql, params) + self._conn.commit() + return QueryResult(rowcount=cur.rowcount) + + def delete( + self, table: str, where: dict[str, Any] | None = None + ) -> QueryResult: + """Delete rows matching the *where* clause.""" + sql = f"DELETE FROM {table}" + params: dict[str, Any] = {} + + if where: + where_parts: list[str] = [] + for i, (k, v) in enumerate(where.items()): + param_name = f"_w{i}" + where_parts.append(f"{k} = :{param_name}") + params[param_name] = v + sql += " WHERE " + " AND ".join(where_parts) + + with self._lock: + cur = self._conn.execute(sql, params) + self._conn.commit() + return QueryResult(rowcount=cur.rowcount) + + def select( + self, + table: str, + columns: list[str] | None = None, + where: dict[str, Any] | None = None, + order_by: str | None = None, + limit: int | None = None, + offset: int = 0, + ) -> QueryResult: + """Select rows with optional filtering, ordering, and pagination.""" + cols = ", ".join(columns) if columns else "*" + sql = f"SELECT {cols} FROM {table}" + params: dict[str, Any] = {} + + if where: + where_parts: list[str] = [] + for i, (k, v) in enumerate(where.items()): + param_name = f"_w{i}" + where_parts.append(f"{k} = :{param_name}") + params[param_name] = v + sql += " WHERE " + " AND ".join(where_parts) + + if order_by: + sql += f" ORDER BY {order_by}" + + if limit is not None: + sql += " LIMIT :_limit OFFSET :_offset" + params["_limit"] = limit + params["_offset"] = offset + + rows = self._conn.execute(sql, params).fetchall() + return QueryResult( + rows=[dict(r) for r in rows], + rowcount=len(rows), + ) + + def execute( + self, sql: str, params: dict[str, Any] | None = None + ) -> QueryResult: + """Execute raw SQL and return the result.""" + with self._lock: + cur = self._conn.execute(sql, params or {}) + try: + rows = cur.fetchall() + return QueryResult( + rows=[dict(r) for r in rows], + rowcount=cur.rowcount, + lastrowid=cur.lastrowid, + ) + except sqlite3.ProgrammingError: + # No result set (e.g. DDL, DML without returning) + return QueryResult( + rowcount=cur.rowcount, + lastrowid=cur.lastrowid, + ) + + # ------------------------------------------------------------------ + # Transactions + # ------------------------------------------------------------------ + + def begin(self) -> None: + """Begin an explicit transaction.""" + self._conn.execute("BEGIN") + + def commit(self) -> None: + """Commit the current transaction.""" + self._conn.commit() + + def rollback(self) -> None: + """Roll back the current transaction.""" + self._conn.rollback() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """Close the database connection.""" + self._conn.close() + + def __repr__(self) -> str: + return f"SQLiteBackend(path={self._db_path!r})" + + +# --------------------------------------------------------------------------- +# Memory backend (in-memory, for testing) +# --------------------------------------------------------------------------- + + +class MemoryBackend: + """Ephemeral in-memory backend backed by plain Python dicts. + + Useful for unit tests and transient usage where persistence is not + needed. Thread-safe via a reentrant lock. + + Parameters + ---------- + name : str + Human-readable name for this backend (used in logging). + """ + + def __init__(self, name: str = "memory") -> None: + self._name = name + self._tables: dict[str, list[dict[str, Any]]] = {} + self._schemas: dict[str, TableSchema] = {} + self._lock = threading.RLock() + self._auto_id: int = 0 + + @property + def table_names(self) -> list[str]: + """Return a sorted list of table names.""" + return sorted(self._tables.keys()) + + # ------------------------------------------------------------------ + # Schema + # ------------------------------------------------------------------ + + def create_table(self, schema: TableSchema) -> None: + """Create a table if it does not exist.""" + with self._lock: + if schema.name not in self._tables: + self._tables[schema.name] = [] + self._schemas[schema.name] = schema + + def drop_table(self, name: str) -> None: + """Drop a table if it exists.""" + with self._lock: + self._tables.pop(name, None) + self._schemas.pop(name, None) + + def table_exists(self, name: str) -> bool: + """Check whether a table exists.""" + return name in self._tables + + # ------------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------------ + + def insert(self, table: str, data: dict[str, Any]) -> QueryResult: + """Insert a row into the in-memory table.""" + if table not in self._tables: + raise ValueError(f"Table '{table}' does not exist") + with self._lock: + row = dict(data) + # Auto-assign _id if the schema has a primary key not provided + schema = self._schemas.get(table) + if schema: + for col in schema.columns: + if col.primary_key and col.name not in row: + self._auto_id += 1 + row[col.name] = self._auto_id + self._tables[table].append(row) + self._auto_id += 1 + return QueryResult(rowcount=1, lastrowid=self._auto_id) + + def update( + self, + table: str, + data: dict[str, Any], + where: dict[str, Any] | None = None, + ) -> QueryResult: + """Update matching rows in the in-memory table.""" + if table not in self._tables: + raise ValueError(f"Table '{table}' does not exist") + with self._lock: + count = 0 + for row in self._tables[table]: + if self._matches(row, where): + row.update(data) + count += 1 + return QueryResult(rowcount=count) + + def delete( + self, table: str, where: dict[str, Any] | None = None + ) -> QueryResult: + """Delete matching rows from the in-memory table.""" + if table not in self._tables: + raise ValueError(f"Table '{table}' does not exist") + with self._lock: + original_len = len(self._tables[table]) + self._tables[table] = [ + row for row in self._tables[table] + if not self._matches(row, where) + ] + deleted = original_len - len(self._tables[table]) + return QueryResult(rowcount=deleted) + + def select( + self, + table: str, + columns: list[str] | None = None, + where: dict[str, Any] | None = None, + order_by: str | None = None, + limit: int | None = None, + offset: int = 0, + ) -> QueryResult: + """Select rows with optional filtering and pagination.""" + if table not in self._tables: + raise ValueError(f"Table '{table}' does not exist") + with self._lock: + rows = [r for r in self._tables[table] if self._matches(r, where)] + + # Column projection + if columns: + rows = [{k: r.get(k) for k in columns} for r in rows] + else: + rows = [dict(r) for r in rows] + + # Ordering + if order_by: + desc = order_by.endswith(" DESC") + col = order_by.replace(" DESC", "").replace(" ASC", "").strip() + rows.sort( + key=lambda r: (r.get(col) is None, r.get(col, "")), + reverse=desc, + ) + + # Pagination + rows = rows[offset:] + if limit is not None: + rows = rows[:limit] + + return QueryResult(rows=rows, rowcount=len(rows)) + + def execute( + self, sql: str, params: dict[str, Any] | None = None + ) -> QueryResult: + """Execute raw SQL — not supported for MemoryBackend. + + Raises + ------ + NotImplementedError + Always, since raw SQL is not parsed by this backend. + """ + raise NotImplementedError( + "MemoryBackend does not support raw SQL. Use the CRUD methods." + ) + + # ------------------------------------------------------------------ + # Transactions (no-ops for in-memory) + # ------------------------------------------------------------------ + + def begin(self) -> None: + """No-op — MemoryBackend has no transaction support.""" + + def commit(self) -> None: + """No-op — MemoryBackend has no transaction support.""" + + def rollback(self) -> None: + """No-op — MemoryBackend has no transaction support.""" + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """Clear all in-memory data.""" + with self._lock: + self._tables.clear() + self._schemas.clear() + + def __repr__(self) -> str: + return f"MemoryBackend(name={self._name!r}, tables={self.table_names})" + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _matches( + row: dict[str, Any], where: dict[str, Any] | None + ) -> bool: + """Check whether *row* matches all key-value pairs in *where*.""" + if where is None: + return True + return all(row.get(k) == v for k, v in where.items()) + + +# --------------------------------------------------------------------------- +# Connection wrapper (context manager) +# --------------------------------------------------------------------------- + + +class DatabaseConnection: + """Context manager wrapping any :class:`DatabaseBackend`. + + Provides automatic commit on clean exit and rollback on exception. + + Parameters + ---------- + backend : DatabaseBackend + The backend instance to wrap. + + Examples + -------- + >>> backend = MemoryBackend() + >>> backend.create_table(TableSchema("t", [ColumnDef("id", "INTEGER", primary_key=True)])) + >>> with DatabaseConnection(backend) as conn: + ... conn.insert("t", {"id": 1}) + ... result = conn.select("t") + """ + + def __init__(self, backend: DatabaseBackend) -> None: + self._backend = backend + + @property + def backend(self) -> DatabaseBackend: + """Access the underlying backend.""" + return self._backend + + def __enter__(self) -> DatabaseBackend: + self._backend.begin() + return self._backend + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> bool: + if exc_type is not None: + self._backend.rollback() + else: + self._backend.commit() + return False # do not suppress exceptions + + def insert(self, table: str, data: dict[str, Any]) -> QueryResult: + """Convenience proxy to backend.insert().""" + return self._backend.insert(table, data) + + def update( + self, + table: str, + data: dict[str, Any], + where: dict[str, Any] | None = None, + ) -> QueryResult: + """Convenience proxy to backend.update().""" + return self._backend.update(table, data, where) + + def delete( + self, table: str, where: dict[str, Any] | None = None + ) -> QueryResult: + """Convenience proxy to backend.delete().""" + return self._backend.delete(table, where) + + def select( + self, + table: str, + columns: list[str] | None = None, + where: dict[str, Any] | None = None, + order_by: str | None = None, + limit: int | None = None, + offset: int = 0, + ) -> QueryResult: + """Convenience proxy to backend.select().""" + return self._backend.select(table, columns, where, order_by, limit, offset) + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def create_backend( + backend_type: str = "sqlite", + **kwargs: Any, +) -> DatabaseBackend: + """Factory function to create a database backend. + + Parameters + ---------- + backend_type : str + One of ``"sqlite"`` or ``"memory"``. + **kwargs + Passed to the backend constructor. + + Returns + ------- + DatabaseBackend + The created backend instance. + + Raises + ------ + ValueError + If *backend_type* is not recognised. + + Examples + -------- + >>> db = create_backend("sqlite", db_path="/tmp/test.db") + >>> db = create_backend("memory", name="test") + """ + if backend_type == "sqlite": + return SQLiteBackend(**kwargs) + if backend_type == "memory": + return MemoryBackend(**kwargs) + raise ValueError( + f"Unknown backend type '{backend_type}'. " + f"Supported: 'sqlite', 'memory'" + ) diff --git a/skillforge/core/registry.py b/skillforge/core/registry.py index 400f1b2..3ca10cd 100644 --- a/skillforge/core/registry.py +++ b/skillforge/core/registry.py @@ -52,7 +52,7 @@ class SkillRegistry: def __init__(self, db_path: str | Path | None = None) -> None: self._db_path = str(db_path or Path.home() / ".skillforge" / "skills.db") Path(self._db_path).parent.mkdir(parents=True, exist_ok=True) - self._conn = sqlite3.connect(self._db_path) + self._conn = sqlite3.connect(self._db_path, check_same_thread=False) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA journal_mode=WAL") self._conn.execute("PRAGMA foreign_keys=ON") diff --git a/skillforge/core/resilience.py b/skillforge/core/resilience.py new file mode 100644 index 0000000..9ebf200 --- /dev/null +++ b/skillforge/core/resilience.py @@ -0,0 +1,737 @@ +"""Resilience & Error Handling Module. + +Provides production-grade fault tolerance primitives: + +- :class:`CircuitBreaker` — prevents cascading failures by short-circuiting + calls to a failing dependency after a configurable error threshold. +- :class:`RetryPolicy` — configurable retry with exponential backoff and + jitter for transient failures. +- :class:`Bulkhead` — concurrency limiter that isolates resource pools. +- :class:`GracefulDegradation` — cascading fallback chains. +- :class:`ResilientExecutor` — composable executor that chains a circuit + breaker, retry policy, and bulkhead around any callable. + +All classes are stdlib-only with full type hints. Thread-safety is achieved +via :class:`threading.Lock` where shared mutable state exists. +""" + +from __future__ import annotations + +import enum +import logging +import random +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable, Generic, TypeVar + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +# ====================================================================== +# Circuit Breaker +# ====================================================================== + + +class CircuitState(enum.Enum): + """Circuit breaker states. + + * **CLOSED** — normal operation; calls pass through. + * **OPEN** — calls are rejected immediately with :class:`CircuitOpenError`. + * **HALF_OPEN** — a limited number of probe calls are allowed through + to test whether the downstream has recovered. + """ + + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +@dataclass +class CircuitStats: + """Snapshot of circuit breaker statistics.""" + + state: CircuitState + failure_count: int + success_count: int + total_calls: int + consecutive_failures: int + last_failure_time: datetime | None + last_state_change: datetime + + +class CircuitOpenError(Exception): + """Raised when the circuit breaker is in the OPEN state.""" + + def __init__(self, name: str, remaining_seconds: float) -> None: + self.name = name + self.remaining_seconds = remaining_seconds + super().__init__( + f"Circuit '{name}' is OPEN. " + f"Retry in {remaining_seconds:.1f}s." + ) + + +class CircuitBreaker: + """State-machine circuit breaker. + + Parameters + ---------- + name : str + Human-readable identifier for logging / diagnostics. + failure_threshold : int + Number of consecutive failures before the circuit opens. + recovery_timeout : float + Seconds to wait in OPEN before transitioning to HALF_OPEN. + half_open_max : int + Number of probe calls allowed in HALF_OPEN before deciding. + excluded_exceptions : tuple[type[BaseException], ...] + Exception types that **should not** count as failures (e.g. + validation errors that are not the downstream's fault). + """ + + def __init__( + self, + name: str = "default", + failure_threshold: int = 5, + recovery_timeout: float = 30.0, + half_open_max: int = 1, + excluded_exceptions: tuple[type[BaseException], ...] = (), + ) -> None: + self._name = name + self._failure_threshold = failure_threshold + self._recovery_timeout = recovery_timeout + self._half_open_max = half_open_max + self._excluded_exceptions = excluded_exceptions + + self._state = CircuitState.CLOSED + self._failure_count = 0 + self._success_count = 0 + self._total_calls = 0 + self._consecutive_failures = 0 + self._half_open_calls = 0 + self._last_failure_time: datetime | None = None + self._last_state_change = datetime.now(timezone.utc) + self._opened_at: float = 0.0 + self._lock = threading.Lock() + + # -- public API --------------------------------------------------- + + @property + def state(self) -> CircuitState: + """Current state, auto-transitioning from OPEN when timeout elapses.""" + with self._lock: + self._maybe_half_open() + return self._state + + def call(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: + """Execute *fn* through the circuit breaker. + + Parameters + ---------- + fn : Callable[..., T] + The protected callable. + *args, **kwargs + Forwarded to *fn*. + + Returns + ------- + T + The return value of *fn*. + + Raises + ------ + CircuitOpenError + If the circuit is OPEN and the recovery timeout has not elapsed. + Exception + Any exception raised by *fn* (after recording it). + """ + with self._lock: + self._maybe_half_open() + state = self._state + + if state == CircuitState.OPEN: + remaining = self._recovery_timeout - (time.monotonic() - self._opened_at) + raise CircuitOpenError(self._name, max(0.0, remaining)) + + if state == CircuitState.HALF_OPEN: + if self._half_open_calls >= self._half_open_max: + remaining = self._recovery_timeout - ( + time.monotonic() - self._opened_at + ) + raise CircuitOpenError(self._name, max(0.0, remaining)) + self._half_open_calls += 1 + + self._total_calls += 1 + + # Execute outside the lock so the callable can be long-running. + try: + result = fn(*args, **kwargs) + except self._excluded_exceptions: + raise # don't count as failure + except Exception: + self._record_failure() + raise + else: + self._record_success() + return result + + def record_success(self) -> None: + """Manually record a success (useful for external integrations).""" + self._record_success() + + def record_failure(self) -> None: + """Manually record a failure.""" + self._record_failure() + + def reset(self) -> None: + """Reset the breaker to the CLOSED state with zero counters.""" + with self._lock: + self._state = CircuitState.CLOSED + self._failure_count = 0 + self._success_count = 0 + self._consecutive_failures = 0 + self._half_open_calls = 0 + self._last_failure_time = None + self._last_state_change = datetime.now(timezone.utc) + + def get_stats(self) -> CircuitStats: + """Return a snapshot of current statistics.""" + with self._lock: + self._maybe_half_open() + return CircuitStats( + state=self._state, + failure_count=self._failure_count, + success_count=self._success_count, + total_calls=self._total_calls, + consecutive_failures=self._consecutive_failures, + last_failure_time=self._last_failure_time, + last_state_change=self._last_state_change, + ) + + # -- internals ---------------------------------------------------- + + def _record_success(self) -> None: + with self._lock: + self._success_count += 1 + self._consecutive_failures = 0 + if self._state == CircuitState.HALF_OPEN: + # Probe succeeded → close + self._transition(CircuitState.CLOSED) + self._half_open_calls = 0 + + def _record_failure(self) -> None: + with self._lock: + self._failure_count += 1 + self._consecutive_failures += 1 + self._last_failure_time = datetime.now(timezone.utc) + + if self._state == CircuitState.HALF_OPEN: + # Probe failed → re-open + self._transition(CircuitState.OPEN) + elif ( + self._state == CircuitState.CLOSED + and self._consecutive_failures >= self._failure_threshold + ): + self._transition(CircuitState.OPEN) + + def _transition(self, new_state: CircuitState) -> None: + if self._state == new_state: + return + logger.info( + "Circuit '%s': %s → %s", + self._name, + self._state.value, + new_state.value, + ) + self._state = new_state + self._last_state_change = datetime.now(timezone.utc) + if new_state == CircuitState.OPEN: + self._opened_at = time.monotonic() + self._half_open_calls = 0 + + def _maybe_half_open(self) -> None: + if self._state != CircuitState.OPEN: + return + elapsed = time.monotonic() - self._opened_at + if elapsed >= self._recovery_timeout: + self._transition(CircuitState.HALF_OPEN) + + def __repr__(self) -> str: + return ( + f"CircuitBreaker(name={self._name!r}, " + f"state={self._state.value}, " + f"failures={self._consecutive_failures}/{self._failure_threshold})" + ) + + +# ====================================================================== +# Retry Policy +# ====================================================================== + + +@dataclass +class RetryEvent: + """Record of a single retry attempt.""" + + attempt: int + delay: float + exception: Exception + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +class RetryExhaustedError(Exception): + """Raised when all retry attempts have been exhausted.""" + + def __init__(self, attempts: int, last_exception: BaseException) -> None: + self.attempts = attempts + self.last_exception = last_exception + super().__init__( + f"All {attempts} retry attempts exhausted. " + f"Last error: {last_exception!r}" + ) + + +class RetryPolicy: + """Configurable retry with exponential back-off and jitter. + + Parameters + ---------- + max_attempts : int + Total number of attempts (including the first call). ``1`` means + no retries. + base_delay : float + Initial delay in seconds between retries. + max_delay : float + Cap on the delay (prevents unbounded exponential growth). + backoff_factor : float + Multiplier applied to the delay after each attempt. + jitter : bool + If *True*, uniform random jitter of ±50 % is added to each delay. + retriable_exceptions : tuple[type[BaseException], ...] + Exception types that trigger a retry. Non-retriable exceptions + propagate immediately. + on_retry : Callable[[RetryEvent], None] | None + Optional callback invoked before each retry sleep. + """ + + def __init__( + self, + max_attempts: int = 3, + base_delay: float = 1.0, + max_delay: float = 60.0, + backoff_factor: float = 2.0, + jitter: bool = True, + retriable_exceptions: tuple[type[BaseException], ...] = (Exception,), + on_retry: Callable[[RetryEvent], None] | None = None, + ) -> None: + if max_attempts < 1: + raise ValueError("max_attempts must be >= 1") + if base_delay < 0: + raise ValueError("base_delay must be >= 0") + if backoff_factor < 1.0: + raise ValueError("backoff_factor must be >= 1.0") + + self._max_attempts = max_attempts + self._base_delay = base_delay + self._max_delay = max_delay + self._backoff_factor = backoff_factor + self._jitter = jitter + self._retriable_exceptions = retriable_exceptions + self._on_retry = on_retry + + def execute(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: + """Call *fn* with retries according to this policy. + + Parameters + ---------- + fn : Callable[..., T] + The callable to protect. + *args, **kwargs + Forwarded to *fn*. + + Returns + ------- + T + The return value of *fn* on the first successful attempt. + + Raises + ------ + RetryExhaustedError + If all attempts fail. + Exception + Any non-retriable exception raised by *fn*. + """ + last_exc: BaseException | None = None + for attempt in range(1, self._max_attempts + 1): + try: + return fn(*args, **kwargs) + except self._retriable_exceptions as exc: + last_exc = exc + if attempt == self._max_attempts: + break + + delay = self._compute_delay(attempt) + event = RetryEvent( + attempt=attempt, + delay=delay, + exception=exc if isinstance(exc, Exception) else Exception(str(exc)), + ) + if self._on_retry: + try: + self._on_retry(event) + except Exception: + pass # callback errors must not break retry + logger.debug( + "Retry attempt %d/%d after %.2fs: %s", + attempt, + self._max_attempts, + delay, + exc, + ) + time.sleep(delay) + + raise RetryExhaustedError( + self._max_attempts, last_exc # type: ignore[arg-type] + ) + + def _compute_delay(self, attempt: int) -> float: + delay = self._base_delay * (self._backoff_factor ** (attempt - 1)) + delay = min(delay, self._max_delay) + if self._jitter: + delay = delay * random.uniform(0.5, 1.5) + return max(0.0, delay) + + +# ====================================================================== +# Bulkhead +# ====================================================================== + + +class BulkheadFullError(Exception): + """Raised when the bulkhead concurrency limit is reached.""" + + def __init__(self, name: str, max_concurrent: int) -> None: + self.name = name + self.max_concurrent = max_concurrent + super().__init__( + f"Bulkhead '{name}' is full ({max_concurrent} concurrent callers). " + f"Try again later." + ) + + +@dataclass +class BulkheadStats: + """Snapshot of bulkhead statistics.""" + + name: str + max_concurrent: int + current_executions: int + available_permits: int + total_calls: int + rejected_calls: int + + +class Bulkhead: + """Concurrency limiter / resource pool isolator. + + Parameters + ---------- + name : str + Identifier for logging. + max_concurrent : int + Maximum number of concurrent executions allowed. + max_wait : float + Maximum seconds to wait for a permit. ``0`` means fail immediately. + """ + + def __init__( + self, + name: str = "default", + max_concurrent: int = 10, + max_wait: float = 0.0, + ) -> None: + if max_concurrent < 1: + raise ValueError("max_concurrent must be >= 1") + self._name = name + self._max_concurrent = max_concurrent + self._max_wait = max_wait + self._semaphore = threading.Semaphore(max_concurrent) + self._current = 0 + self._total_calls = 0 + self._rejected_calls = 0 + self._lock = threading.Lock() + + def execute(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: + """Execute *fn* within the bulkhead's concurrency limit. + + Parameters + ---------- + fn : Callable[..., T] + The callable to protect. + *args, **kwargs + Forwarded to *fn*. + + Returns + ------- + T + Return value of *fn*. + + Raises + ------ + BulkheadFullError + If no permit is available within *max_wait* seconds. + """ + acquired = self._semaphore.acquire(timeout=self._max_wait) + if not acquired: + with self._lock: + self._rejected_calls += 1 + raise BulkheadFullError(self._name, self._max_concurrent) + + with self._lock: + self._current += 1 + self._total_calls += 1 + + try: + return fn(*args, **kwargs) + finally: + with self._lock: + self._current -= 1 + self._semaphore.release() + + def get_stats(self) -> BulkheadStats: + """Return a snapshot of bulkhead statistics.""" + with self._lock: + return BulkheadStats( + name=self._name, + max_concurrent=self._max_concurrent, + current_executions=self._current, + available_permits=self._max_concurrent - self._current, + total_calls=self._total_calls, + rejected_calls=self._rejected_calls, + ) + + +# ====================================================================== +# Graceful Degradation +# ====================================================================== + + +class FallbackChainExhaustedError(Exception): + """Raised when every fallback in the chain has failed.""" + + def __init__(self, errors: list[Exception]) -> None: + self.errors = errors + summary = "; ".join(repr(e) for e in errors[-3:]) + super().__init__( + f"All {len(errors)} fallback(s) exhausted. Latest: {summary}" + ) + + +class GracefulDegradation: + """Execute a primary function with cascading fallbacks. + + Parameters + ---------- + name : str + Identifier for logging. + fallbacks : list[Callable[..., Any]] + Ordered list of fallback callables. They share the same signature + as the primary function. + on_fallback : Callable[[int, Exception], None] | None + Callback invoked when a fallback is triggered, receiving the + fallback index (0-based) and the triggering exception. + """ + + def __init__( + self, + name: str = "default", + fallbacks: list[Callable[..., Any]] | None = None, + on_fallback: Callable[[int, Exception], None] | None = None, + ) -> None: + self._name = name + self._fallbacks: list[Callable[..., Any]] = fallbacks or [] + self._on_fallback = on_fallback + + def execute(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: + """Call *fn*, falling back through the chain on failure. + + Parameters + ---------- + fn : Callable[..., T] + Primary callable. + *args, **kwargs + Forwarded to the callable being attempted. + + Returns + ------- + T + Return value of the first successful callable. + + Raises + ------ + FallbackChainExhaustedError + If every callable (primary + fallbacks) raises. + """ + errors: list[Exception] = [] + chain: list[Callable[..., Any]] = [fn] + self._fallbacks + + for idx, callable_ in enumerate(chain): + try: + return callable_(*args, **kwargs) + except Exception as exc: + errors.append(exc) + logger.debug( + "Fallback '%s' attempt %d/%d failed: %s", + self._name, + idx + 1, + len(chain), + exc, + ) + if self._on_fallback and idx < len(chain) - 1: + try: + self._on_fallback(idx, exc) + except Exception: + pass + + raise FallbackChainExhaustedError(errors) + + def add_fallback(self, fn: Callable[..., Any]) -> None: + """Append a fallback callable to the chain.""" + self._fallbacks.append(fn) + + +# ====================================================================== +# Resilient Executor (composable) +# ====================================================================== + + +@dataclass +class ExecutorStats: + """Aggregated statistics from the resilient executor's components.""" + + circuit: CircuitStats | None = None + bulkhead: BulkheadStats | None = None + total_attempts: int = 0 + total_retries: int = 0 + total_successes: int = 0 + total_failures: int = 0 + + +class ResilientExecutor: + """Composable resilience wrapper combining circuit breaker, retry, and + bulkhead around a callable. + + Execution order: + 1. Bulkhead (concurrency check) + 2. Circuit breaker (availability check) + 3. Retry policy (transient failure handling) + 4. Actual callable + + Parameters + ---------- + name : str + Identifier for logging. + circuit_breaker : CircuitBreaker | None + Optional circuit breaker. + retry_policy : RetryPolicy | None + Optional retry policy. + bulkhead : Bulkhead | None + Optional bulkhead. + degradation : GracefulDegradation | None + Optional fallback chain wrapping the entire execution. + """ + + def __init__( + self, + name: str = "default", + circuit_breaker: CircuitBreaker | None = None, + retry_policy: RetryPolicy | None = None, + bulkhead: Bulkhead | None = None, + degradation: GracefulDegradation | None = None, + ) -> None: + self._name = name + self._circuit = circuit_breaker + self._retry = retry_policy + self._bulkhead = bulkhead + self._degradation = degradation + self._total_attempts = 0 + self._total_retries = 0 + self._total_successes = 0 + self._total_failures = 0 + self._lock = threading.Lock() + + def execute(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: + """Execute *fn* through all configured resilience layers. + + Parameters + ---------- + fn : Callable[..., T] + The callable to protect. + *args, **kwargs + Forwarded to *fn*. + + Returns + ------- + T + Return value on success. + + Raises + ------ + Exception + Propagates from the innermost failing layer. + """ + with self._lock: + self._total_attempts += 1 + + def _inner() -> T: + if self._retry: + return self._retry.execute(self._invoke_through_breaker, fn, *args, **kwargs) + return self._invoke_through_breaker(fn, *args, **kwargs) + + try: + if self._bulkhead: + result = self._bulkhead.execute(_inner) + elif self._degradation: + result = self._degradation.execute(_inner) + else: + result = _inner() + + with self._lock: + self._total_successes += 1 + return result + + except Exception: + with self._lock: + self._total_failures += 1 + raise + + def _invoke_through_breaker(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: + if self._circuit: + return self._circuit.call(fn, *args, **kwargs) + return fn(*args, **kwargs) + + def get_stats(self) -> ExecutorStats: + """Return aggregated statistics from all layers.""" + with self._lock: + return ExecutorStats( + circuit=self._circuit.get_stats() if self._circuit else None, + bulkhead=self._bulkhead.get_stats() if self._bulkhead else None, + total_attempts=self._total_attempts, + total_retries=self._total_retries, + total_successes=self._total_successes, + total_failures=self._total_failures, + ) + + def __repr__(self) -> str: + parts = [f"name={self._name!r}"] + if self._circuit: + parts.append(f"circuit={self._circuit._name!r}") + if self._retry: + parts.append(f"retry(max={self._retry._max_attempts})") + if self._bulkhead: + parts.append(f"bulkhead={self._bulkhead._name!r}") + return f"ResilientExecutor({', '.join(parts)})" diff --git a/skillforge/core/tracker.py b/skillforge/core/tracker.py index a0437d7..ebaf1d1 100644 --- a/skillforge/core/tracker.py +++ b/skillforge/core/tracker.py @@ -32,7 +32,7 @@ class QValueTracker: def __init__(self, db_path: str | Path | None = None) -> None: self._db_path = str(db_path or Path.home() / ".skillforge" / "tracker.db") Path(self._db_path).parent.mkdir(parents=True, exist_ok=True) - self._conn = sqlite3.connect(self._db_path) + self._conn = sqlite3.connect(self._db_path, check_same_thread=False) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA journal_mode=WAL") self._ensure_tables() diff --git a/skillforge/integrations/__init__.py b/skillforge/integrations/__init__.py index 563376d..c2de0a4 100644 --- a/skillforge/integrations/__init__.py +++ b/skillforge/integrations/__init__.py @@ -1 +1,29 @@ -"""SkillForge Integrations.""" +"""SkillForge Integrations — adapters and platform bridges.""" + +from skillforge.integrations.adapters import ( + IntegrationAdapter, + AdapterRegistry, + AdapterConfig, + AdapterResult, + AdapterStatus, + EventPayload, + EventType, + GitHubAdapter, + SlackAdapter, + WebhookAdapter, + FileAdapter, +) + +__all__ = [ + "IntegrationAdapter", + "AdapterRegistry", + "AdapterConfig", + "AdapterResult", + "AdapterStatus", + "EventPayload", + "EventType", + "GitHubAdapter", + "SlackAdapter", + "WebhookAdapter", + "FileAdapter", +] diff --git a/skillforge/integrations/adapters.py b/skillforge/integrations/adapters.py new file mode 100644 index 0000000..1f945d2 --- /dev/null +++ b/skillforge/integrations/adapters.py @@ -0,0 +1,957 @@ +"""Integration adapters for connecting SkillForge with external platforms. + +Adapters provide a unified interface for triggering actions on external +systems in response to SkillForge events (skill creation, experiment +results, etc.) and for ingesting data from external sources. + +Each adapter implements the :class:`IntegrationAdapter` protocol and is +registered with an :class:`AdapterRegistry` for discovery and dispatch. + +Currently implemented adapters: + +- :class:`GitHubAdapter` — syncs skills with a GitHub repository via + the REST API (uses ``urllib``, no ``requests`` dependency). +- :class:`SlackAdapter` — posts notifications to a Slack channel via + an incoming webhook. +- :class:`WebhookAdapter` — generic HTTP webhook adapter for any + custom endpoint. +- :class:`FileAdapter` — exports skills as JSON or markdown files to + a local directory. + +No external dependencies — stdlib only. +""" + +from __future__ import annotations + +import json +import logging +import urllib.request +import urllib.error +import urllib.parse +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any, Protocol, runtime_checkable + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class AdapterStatus(Enum): + """Operational status of an adapter.""" + + INACTIVE = "inactive" + ACTIVE = "active" + ERROR = "error" + + +class EventType(Enum): + """Types of SkillForge events that adapters can react to.""" + + SKILL_CREATED = "skill_created" + SKILL_UPDATED = "skill_updated" + SKILL_DELETED = "skill_deleted" + EXPERIMENT_STARTED = "experiment_started" + EXPERIMENT_COMPLETED = "experiment_completed" + METRIC_ALERT = "metric_alert" + SYNC_REQUEST = "sync_request" + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +@dataclass +class AdapterConfig: + """Configuration for an adapter. + + Attributes + ---------- + name : str + Unique adapter name. + adapter_type : str + Type identifier (e.g. ``"github"``, ``"slack"``). + enabled : bool + Whether this adapter is active. + config : dict[str, Any] + Adapter-specific configuration (URLs, tokens, etc.). + events : list[EventType] + Which events trigger this adapter. + """ + + name: str + adapter_type: str + enabled: bool = True + config: dict[str, Any] = field(default_factory=dict) + events: list[EventType] = field(default_factory=list) + + +@dataclass +class EventPayload: + """Data payload for an adapter event. + + Attributes + ---------- + event_type : EventType + The type of event. + data : dict[str, Any] + Event-specific data (skill_id, experiment_id, metrics, etc.). + timestamp : str + ISO-8601 UTC timestamp of the event. + source : str + Identifier of the component that emitted the event. + """ + + event_type: EventType + data: dict[str, Any] = field(default_factory=dict) + timestamp: str = "" + source: str = "skillforge" + + def __post_init__(self) -> None: + if not self.timestamp: + self.timestamp = datetime.now(timezone.utc).isoformat() + + +@dataclass +class AdapterResult: + """Result of an adapter invocation. + + Attributes + ---------- + adapter_name : str + Name of the adapter that was invoked. + success : bool + Whether the operation succeeded. + message : str + Human-readable status message. + response_data : dict[str, Any] + Any data returned by the external system. + duration_ms : float + Wall-clock time in milliseconds. + """ + + adapter_name: str + success: bool + message: str = "" + response_data: dict[str, Any] = field(default_factory=dict) + duration_ms: float = 0.0 + + +# --------------------------------------------------------------------------- +# Protocol +# --------------------------------------------------------------------------- + + +@runtime_checkable +class IntegrationAdapter(Protocol): + """Protocol that all integration adapters must satisfy.""" + + @property + def name(self) -> str: + """Unique adapter name.""" + ... + + @property + def status(self) -> AdapterStatus: + """Current operational status.""" + ... + + def dispatch(self, event: EventPayload) -> AdapterResult: + """Handle an event and return the result.""" + ... + + def health_check(self) -> bool: + """Return whether the adapter can reach its external system.""" + ... + + +# --------------------------------------------------------------------------- +# HTTP helper (stdlib urllib) +# --------------------------------------------------------------------------- + + +def _http_request( + url: str, + method: str = "POST", + body: bytes | None = None, + headers: dict[str, str] | None = None, + timeout: int = 30, +) -> tuple[int, str]: + """Execute an HTTP request using stdlib urllib. + + Parameters + ---------- + url : str + The target URL. + method : str + HTTP method (``GET``, ``POST``, ``PUT``, ``DELETE``). + body : bytes | None + Request body (e.g. JSON-encoded). + headers : dict[str, str] | None + HTTP headers. + timeout : int + Request timeout in seconds. + + Returns + ------- + tuple[int, str] + ``(status_code, response_body)``. + """ + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Content-Type", "application/json") + if headers: + for key, value in headers.items(): + req.add_header(key, value) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + response_body = resp.read().decode("utf-8", errors="replace") + return resp.status, response_body + except urllib.error.HTTPError as exc: + response_body = exc.read().decode("utf-8", errors="replace") if exc.fp else "" + return exc.code, response_body + except urllib.error.URLError as exc: + raise ConnectionError(f"HTTP request failed: {exc.reason}") from exc + except TimeoutError as exc: + raise TimeoutError(f"HTTP request timed out after {timeout}s") from exc + + +# --------------------------------------------------------------------------- +# GitHub adapter +# --------------------------------------------------------------------------- + + +class GitHubAdapter: + """Sync skills with a GitHub repository using the REST API. + + Parameters + ---------- + token : str + GitHub personal access token (PAT). + repo : str + Repository in ``owner/name`` format. + branch : str + Target branch for file updates. + base_url : str + Base API URL (default ``https://api.github.com``). Override for + GitHub Enterprise. + """ + + def __init__( + self, + token: str, + repo: str, + branch: str = "main", + base_url: str = "https://api.github.com", + ) -> None: + self._token = token + self._repo = repo + self._branch = branch + self._base_url = base_url.rstrip("/") + self._status = AdapterStatus.ACTIVE + self._name = f"github:{repo}" + + @property + def name(self) -> str: + return self._name + + @property + def status(self) -> AdapterStatus: + return self._status + + def dispatch(self, event: EventPayload) -> AdapterResult: + """Push a skill file to GitHub on creation/update events. + + For ``SKILL_CREATED`` and ``SKILL_UPDATED`` events, expects + ``event.data`` to contain ``path`` (repository file path), + ``content`` (base64-encoded file content), and optionally + ``commit_message``. + """ + import base64 + import time + + start = time.monotonic() + data = event.data + path = data.get("path", "") + content = data.get("content", "") + + if not path or not content: + return AdapterResult( + adapter_name=self._name, + success=False, + message="Missing 'path' or 'content' in event data", + duration_ms=(time.monotonic() - start) * 1000, + ) + + # Ensure content is base64-encoded + if not data.get("is_base64", False): + content = base64.b64encode(content.encode("utf-8")).decode("ascii") + + url = ( + f"{self._base_url}/repos/{self._repo}/contents/{path}" + f"?ref={self._branch}" + ) + headers = { + "Authorization": f"token {self._token}", + "Accept": "application/vnd.github.v3+json", + } + + # Try to get existing file SHA for update + sha: str | None = None + try: + _, resp_body = _http_request(url, method="GET", headers=headers, timeout=15) + existing = json.loads(resp_body) + sha = existing.get("sha") + except (ConnectionError, json.JSONDecodeError): + pass # File doesn't exist yet — will create + + body: dict[str, Any] = { + "message": data.get( + "commit_message", f"SkillForge: update {path}" + ), + "content": content, + "branch": self._branch, + } + if sha: + body["sha"] = sha + + try: + status_code, resp_body = _http_request( + url, + method="PUT", + body=json.dumps(body).encode("utf-8"), + headers=headers, + ) + elapsed = (time.monotonic() - start) * 1000 + if status_code in (200, 201): + resp_data = json.loads(resp_body) if resp_body else {} + return AdapterResult( + adapter_name=self._name, + success=True, + message=f"Pushed {path} to {self._repo}", + response_data=resp_data, + duration_ms=elapsed, + ) + return AdapterResult( + adapter_name=self._name, + success=False, + message=f"GitHub API returned {status_code}: {resp_body[:200]}", + duration_ms=elapsed, + ) + except Exception as exc: + elapsed = (time.monotonic() - start) * 1000 + self._status = AdapterStatus.ERROR + return AdapterResult( + adapter_name=self._name, + success=False, + message=f"Error: {exc}", + duration_ms=elapsed, + ) + + def health_check(self) -> bool: + """Check if the GitHub API is reachable and token is valid.""" + url = f"{self._base_url}/user" + headers = { + "Authorization": f"token {self._token}", + "Accept": "application/vnd.github.v3+json", + } + try: + status_code, _ = _http_request(url, method="GET", headers=headers, timeout=10) + ok = status_code == 200 + self._status = AdapterStatus.ACTIVE if ok else AdapterStatus.ERROR + return ok + except Exception: + self._status = AdapterStatus.ERROR + return False + + +# --------------------------------------------------------------------------- +# Slack adapter +# --------------------------------------------------------------------------- + + +class SlackAdapter: + """Post notifications to a Slack channel via an incoming webhook. + + Parameters + ---------- + webhook_url : str + Slack incoming webhook URL. + channel : str | None + Override the default webhook channel. + username : str + Bot username for messages. + icon_emoji : str + Emoji avatar for the bot. + """ + + def __init__( + self, + webhook_url: str, + channel: str | None = None, + username: str = "SkillForge", + icon_emoji: str = ":robot_face:", + ) -> None: + self._webhook_url = webhook_url + self._channel = channel + self._username = username + self._icon_emoji = icon_emoji + self._status = AdapterStatus.ACTIVE + self._name = "slack:webhook" + + @property + def name(self) -> str: + return self._name + + @property + def status(self) -> AdapterStatus: + return self._status + + def dispatch(self, event: EventPayload) -> AdapterResult: + """Format the event as a Slack message and post it.""" + import time + + start = time.monotonic() + message = self._format_message(event) + payload: dict[str, Any] = { + "text": message, + "username": self._username, + "icon_emoji": self._icon_emoji, + } + if self._channel: + payload["channel"] = self._channel + + try: + status_code, body = _http_request( + self._webhook_url, + method="POST", + body=json.dumps(payload).encode("utf-8"), + timeout=15, + ) + elapsed = (time.monotonic() - start) * 1000 + if status_code == 200 and body.strip() == "ok": + return AdapterResult( + adapter_name=self._name, + success=True, + message="Posted to Slack", + duration_ms=elapsed, + ) + return AdapterResult( + adapter_name=self._name, + success=False, + message=f"Slack returned {status_code}: {body[:200]}", + duration_ms=elapsed, + ) + except Exception as exc: + elapsed = (time.monotonic() - start) * 1000 + self._status = AdapterStatus.ERROR + return AdapterResult( + adapter_name=self._name, + success=False, + message=f"Error: {exc}", + duration_ms=elapsed, + ) + + def health_check(self) -> bool: + """Send a test message to verify the webhook is alive.""" + try: + payload = { + "text": "🔬 SkillForge health check", + "username": self._username, + "icon_emoji": self._icon_emoji, + } + if self._channel: + payload["channel"] = self._channel + status_code, body = _http_request( + self._webhook_url, + method="POST", + body=json.dumps(payload).encode("utf-8"), + timeout=10, + ) + ok = status_code == 200 and body.strip() == "ok" + self._status = AdapterStatus.ACTIVE if ok else AdapterStatus.ERROR + return ok + except Exception: + self._status = AdapterStatus.ERROR + return False + + def _format_message(self, event: EventPayload) -> str: + """Format an event into a human-readable Slack message.""" + emoji_map = { + EventType.SKILL_CREATED: "🆕", + EventType.SKILL_UPDATED: "🔄", + EventType.SKILL_DELETED: "🗑️", + EventType.EXPERIMENT_STARTED: "🧪", + EventType.EXPERIMENT_COMPLETED: "📊", + EventType.METRIC_ALERT: "⚠️", + EventType.SYNC_REQUEST: "🔁", + } + emoji = emoji_map.get(event.event_type, "📋") + data = event.data + lines: list[str] = [] + + if event.event_type in (EventType.SKILL_CREATED, EventType.SKILL_UPDATED): + skill_name = data.get("name", "Unknown") + skill_id = data.get("skill_id", "") + lines.append( + f"{emoji} *{event.event_type.value.replace('_', ' ').title()}*: " + f"`{skill_name}` (ID: `{skill_id}`)" + ) + elif event.event_type == EventType.EXPERIMENT_COMPLETED: + exp_name = data.get("name", "Unknown") + result = data.get("summary", "No summary available") + lines.append( + f"{emoji} *Experiment Completed*: `{exp_name}`\n{result}" + ) + elif event.event_type == EventType.METRIC_ALERT: + metric = data.get("metric", "unknown") + value = data.get("value", "?") + lines.append(f"{emoji} *Metric Alert*: {metric} = {value}") + else: + lines.append(f"{emoji} *{event.event_type.value}*") + + if event.source: + lines.append(f"_Source: {event.source}_") + lines.append(f"_{event.timestamp}_") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Webhook adapter (generic) +# --------------------------------------------------------------------------- + + +class WebhookAdapter: + """Generic webhook adapter that POSTs events to an arbitrary URL. + + Parameters + ---------- + name : str + Adapter name. + url : str + Target webhook URL. + secret : str | None + Optional shared secret for HMAC signature verification on the + receiving end. + headers : dict[str, str] + Additional headers to include in every request. + """ + + def __init__( + self, + name: str, + url: str, + secret: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: + self._name = name + self._url = url + self._secret = secret + self._extra_headers = headers or {} + self._status = AdapterStatus.ACTIVE + + @property + def name(self) -> str: + return self._name + + @property + def status(self) -> AdapterStatus: + return self._status + + def dispatch(self, event: EventPayload) -> AdapterResult: + """POST the event as JSON to the webhook URL.""" + import time + + start = time.monotonic() + payload = { + "event_type": event.event_type.value, + "data": event.data, + "timestamp": event.timestamp, + "source": event.source, + } + + # Optional HMAC signature + body_bytes = json.dumps(payload, sort_keys=True).encode("utf-8") + headers = dict(self._extra_headers) + + if self._secret: + import hashlib + import hmac as hmac_mod + + signature = hmac_mod.new( + self._secret.encode("utf-8"), + body_bytes, + hashlib.sha256, + ).hexdigest() + headers["X-SkillForge-Signature"] = f"sha256={signature}" + + try: + status_code, resp_body = _http_request( + self._url, + method="POST", + body=body_bytes, + headers=headers, + timeout=15, + ) + elapsed = (time.monotonic() - start) * 1000 + if 200 <= status_code < 300: + resp_data: dict[str, Any] = {} + try: + resp_data = json.loads(resp_body) + except json.JSONDecodeError: + pass + return AdapterResult( + adapter_name=self._name, + success=True, + message=f"Webhook responded with {status_code}", + response_data=resp_data, + duration_ms=elapsed, + ) + return AdapterResult( + adapter_name=self._name, + success=False, + message=f"Webhook returned {status_code}: {resp_body[:200]}", + duration_ms=elapsed, + ) + except Exception as exc: + elapsed = (time.monotonic() - start) * 1000 + self._status = AdapterStatus.ERROR + return AdapterResult( + adapter_name=self._name, + success=False, + message=f"Error: {exc}", + duration_ms=elapsed, + ) + + def health_check(self) -> bool: + """Send a minimal ``GET`` to verify the endpoint is reachable.""" + try: + status_code, _ = _http_request( + self._url, + method="GET", + headers=self._extra_headers, + timeout=5, + ) + self._status = AdapterStatus.ACTIVE + return True + except Exception: + self._status = AdapterStatus.ERROR + return False + + +# --------------------------------------------------------------------------- +# File adapter (local export) +# --------------------------------------------------------------------------- + + +class FileAdapter: + """Export skills/events as JSON or markdown files to a local directory. + + Parameters + ---------- + output_dir : str | Path + Directory to write files to (created if missing). + format : str + Output format — ``"json"`` or ``"markdown"``. + """ + + def __init__( + self, + output_dir: str | Path, + format: str = "json", + ) -> None: + self._output_dir = Path(output_dir).expanduser().resolve() + self._output_dir.mkdir(parents=True, exist_ok=True) + self._format = format.lower() + self._status = AdapterStatus.ACTIVE + self._name = f"file:{self._output_dir.name}" + + @property + def name(self) -> str: + return self._name + + @property + def status(self) -> AdapterStatus: + return self._status + + def dispatch(self, event: EventPayload) -> AdapterResult: + """Write event data to a file in the output directory.""" + import time + + start = time.monotonic() + data = event.data + + # Derive filename + skill_id = data.get("skill_id", data.get("name", "event")) + safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(skill_id)) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + + if self._format == "markdown": + filename = f"{safe_name}_{timestamp}.md" + content = self._to_markdown(event) + else: + filename = f"{safe_name}_{timestamp}.json" + content = json.dumps( + { + "event_type": event.event_type.value, + "data": data, + "timestamp": event.timestamp, + "source": event.source, + }, + indent=2, + ensure_ascii=False, + ) + + filepath = self._output_dir / filename + try: + filepath.write_text(content, encoding="utf-8") + elapsed = (time.monotonic() - start) * 1000 + return AdapterResult( + adapter_name=self._name, + success=True, + message=f"Wrote {filepath}", + response_data={"path": str(filepath)}, + duration_ms=elapsed, + ) + except Exception as exc: + elapsed = (time.monotonic() - start) * 1000 + return AdapterResult( + adapter_name=self._name, + success=False, + message=f"Error writing file: {exc}", + duration_ms=elapsed, + ) + + def health_check(self) -> bool: + """Verify the output directory is writable.""" + try: + test_file = self._output_dir / ".skillforge_health_check" + test_file.write_text("ok", encoding="utf-8") + test_file.unlink() + self._status = AdapterStatus.ACTIVE + return True + except Exception: + self._status = AdapterStatus.ERROR + return False + + @staticmethod + def _to_markdown(event: EventPayload) -> str: + """Convert an event to a simple markdown document.""" + data = event.data + lines = [ + f"# {event.event_type.value.replace('_', ' ').title()}", + "", + f"**Source:** {event.source} ", + f"**Timestamp:** {event.timestamp}", + "", + "## Data", + "", + ] + for key, value in data.items(): + lines.append(f"- **{key}:** `{value}`") + return "\n".join(lines) + "\n" + + +# --------------------------------------------------------------------------- +# Adapter Registry +# --------------------------------------------------------------------------- + + +class AdapterRegistry: + """Central registry for managing integration adapters. + + Provides registration, lookup, and event dispatching across all + configured adapters. + + Examples + -------- + >>> registry = AdapterRegistry() + >>> registry.register(FileAdapter("/tmp/skills")) + >>> registry.dispatch(EventPayload(EventType.SKILL_CREATED, {"name": "test"})) + """ + + def __init__(self) -> None: + self._adapters: dict[str, IntegrationAdapter] = {} + + @property + def adapter_names(self) -> list[str]: + """Return names of all registered adapters.""" + return list(self._adapters.keys()) + + @property + def adapter_count(self) -> int: + """Return the number of registered adapters.""" + return len(self._adapters) + + def register(self, adapter: IntegrationAdapter) -> None: + """Register an adapter. + + Parameters + ---------- + adapter : IntegrationAdapter + The adapter instance to register. + + Raises + ------ + TypeError + If *adapter* does not satisfy the + :class:`IntegrationAdapter` protocol. + """ + if not isinstance(adapter, IntegrationAdapter): + raise TypeError( + f"Object {type(adapter).__name__} does not implement " + f"IntegrationAdapter protocol" + ) + self._adapters[adapter.name] = adapter + logger.info("Registered adapter: %s", adapter.name) + + def unregister(self, name: str) -> bool: + """Unregister an adapter by name. + + Returns + ------- + bool + ``True`` if the adapter was found and removed. + """ + if name in self._adapters: + del self._adapters[name] + logger.info("Unregistered adapter: %s", name) + return True + return False + + def get(self, name: str) -> IntegrationAdapter | None: + """Retrieve an adapter by name.""" + return self._adapters.get(name) + + def dispatch( + self, + event: EventPayload, + target_adapters: list[str] | None = None, + ) -> list[AdapterResult]: + """Dispatch an event to registered adapters. + + Parameters + ---------- + event : EventPayload + The event to dispatch. + target_adapters : list[str] | None + If provided, only these adapters receive the event. + Otherwise all registered adapters are notified. + + Returns + ------- + list[AdapterResult] + Results from each adapter. + """ + results: list[AdapterResult] = [] + adapters = ( + [self._adapters[n] for n in target_adapters if n in self._adapters] + if target_adapters + else list(self._adapters.values()) + ) + + for adapter in adapters: + try: + result = adapter.dispatch(event) + results.append(result) + if not result.success: + logger.warning( + "Adapter '%s' failed: %s", + adapter.name, + result.message, + ) + except Exception as exc: + logger.exception("Adapter '%s' raised exception", adapter.name) + results.append( + AdapterResult( + adapter_name=adapter.name, + success=False, + message=f"Unhandled exception: {exc}", + ) + ) + + return results + + def health_check_all(self) -> dict[str, bool]: + """Run health checks on all registered adapters. + + Returns + ------- + dict[str, bool] + Mapping of adapter name to health status. + """ + results: dict[str, bool] = {} + for name, adapter in self._adapters.items(): + try: + results[name] = adapter.health_check() + except Exception: + logger.exception("Health check failed for '%s'", name) + results[name] = False + return results + + def list_adapters( + self, status: AdapterStatus | None = None + ) -> list[dict[str, Any]]: + """List registered adapters with their status. + + Parameters + ---------- + status : AdapterStatus | None + If provided, filter by this status. + + Returns + ------- + list[dict[str, Any]] + Each dict has keys ``name``, ``type``, ``status``. + """ + adapters: list[dict[str, Any]] = [] + for adapter in self._adapters.values(): + if status is not None and adapter.status != status: + continue + adapters.append( + { + "name": adapter.name, + "type": type(adapter).__name__, + "status": adapter.status.value, + } + ) + return adapters + + def dispatch_to_file( + self, + event: EventPayload, + target_adapters: list[str] | None = None, + ) -> dict[str, Any]: + """Dispatch an event and return a serialisable summary. + + Useful for programmatic consumption rather than human logging. + + Returns + ------- + dict[str, Any] + Summary with ``total``, ``succeeded``, ``failed``, and + ``results`` keys. + """ + results = self.dispatch(event, target_adapters) + return { + "total": len(results), + "succeeded": sum(1 for r in results if r.success), + "failed": sum(1 for r in results if not r.success), + "results": [ + { + "adapter": r.adapter_name, + "success": r.success, + "message": r.message, + "duration_ms": r.duration_ms, + } + for r in results + ], + } diff --git a/skillforge/intelligence/__init__.py b/skillforge/intelligence/__init__.py index 6b97c49..e8c5c01 100644 --- a/skillforge/intelligence/__init__.py +++ b/skillforge/intelligence/__init__.py @@ -1,10 +1,18 @@ -"""SkillForge Intelligence - Advanced analytics, optimization, and self-improvement.""" +"""SkillForge Intelligence - Advanced analytics, optimization, self-improvement, and alerting.""" from skillforge.intelligence.analyzer import SkillAnalyzer, SkillCluster, UsagePattern from skillforge.intelligence.conflict_detector import ConflictDetector, SkillConflict from skillforge.intelligence.health_monitor import HealthMonitor, HealthStatus, SkillHealth from skillforge.intelligence.optimizer import OptimizationAction, SkillOptimizer from skillforge.intelligence.skill_creator import SkillCreator, Trajectory +from skillforge.intelligence.alert_manager import ( + AlertManager, + AlertRule, + AlertRuleType, + AlertSeverity, + AlertStatus, + Alert, +) __all__ = [ "SkillAnalyzer", @@ -19,4 +27,10 @@ "OptimizationAction", "SkillCreator", "Trajectory", + "AlertManager", + "AlertRule", + "AlertRuleType", + "AlertSeverity", + "AlertStatus", + "Alert", ] diff --git a/skillforge/intelligence/alert_manager.py b/skillforge/intelligence/alert_manager.py new file mode 100644 index 0000000..74eec63 --- /dev/null +++ b/skillforge/intelligence/alert_manager.py @@ -0,0 +1,802 @@ +"""Alert Manager — rule-based alerting for skill health and performance. + +Monitors skill performance metrics via configurable rules and generates +alerts when thresholds are breached, trends are detected, or anomalies +occur. Alerts progress through a lifecycle: ACTIVE → ACKNOWLEDGED → RESOLVED. + +Supports callback-based notifications so external systems (e.g. messaging, +dashboards) can be wired in without coupling. + +All alert history is persisted in SQLite for audit trails. + +No external dependencies — stdlib only. +""" + +from __future__ import annotations + +import json +import logging +import re +import sqlite3 +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from enum import Enum +from pathlib import Path +from typing import Any, Callable, Optional + +from skillforge.core.registry import SkillRegistry +from skillforge.core.tracker import QValueTracker +from skillforge.intelligence.health_monitor import HealthMonitor, HealthStatus + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class AlertSeverity(Enum): + """Severity levels for alerts.""" + + INFO = "info" + WARNING = "warning" + CRITICAL = "critical" + + +class AlertStatus(Enum): + """Lifecycle states an alert can be in.""" + + ACTIVE = "active" + ACKNOWLEDGED = "acknowledged" + RESOLVED = "resolved" + + +class AlertRuleType(Enum): + """Types of alert rules.""" + + THRESHOLD = "threshold" + TREND = "trend" + ANOMALY = "anomaly" + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +@dataclass +class AlertRule: + """A monitoring rule that defines when an alert should fire. + + Attributes + ---------- + id : str + Unique rule identifier. + name : str + Human-readable name. + rule_type : AlertRuleType + The type of rule (threshold, trend, anomaly). + skill_id : str | None + If set, only monitor this skill. ``None`` means all skills. + metric : str + The metric to evaluate. Supported: ``health_score``, ``q_value``, + ``success_rate``, ``failure_rate``, ``usage_count``. + threshold : float + The threshold value. For ``threshold`` rules: alert fires when + metric < threshold. For ``trend`` rules: alert fires when the + metric drops by more than *threshold* between consecutive checks. + severity : AlertSeverity + Severity of the alert when fired. + cooldown_minutes : int + Minimum minutes between re-firing for the same skill. + enabled : bool + Whether this rule is currently active. + description : str + Human-readable description of the rule. + """ + + id: str + name: str + rule_type: AlertRuleType = AlertRuleType.THRESHOLD + skill_id: str | None = None + metric: str = "health_score" + threshold: float = 0.3 + severity: AlertSeverity = AlertSeverity.WARNING + cooldown_minutes: int = 60 + enabled: bool = True + description: str = "" + + +@dataclass +class Alert: + """A fired alert with full context. + + Attributes + ---------- + id : str + Unique alert identifier. + rule_id : str + ID of the rule that generated this alert. + rule_name : str + Name of the rule for quick reference. + skill_id : str + The skill that triggered the alert. + severity : AlertSeverity + Severity level. + status : AlertStatus + Current lifecycle state. + message : str + Human-readable alert message. + metric_value : float + The metric value that triggered the alert. + threshold : float + The threshold that was breached. + created_at : str + ISO-8601 UTC timestamp. + acknowledged_at : str | None + When the alert was acknowledged. + resolved_at : str | None + When the alert was resolved. + metadata : dict[str, Any] + Additional context (skill name, health report, etc.). + """ + + id: str + rule_id: str + rule_name: str + skill_id: str + severity: AlertSeverity + status: AlertStatus + message: str + metric_value: float + threshold: float + created_at: str + acknowledged_at: str | None = None + resolved_at: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# AlertManager +# --------------------------------------------------------------------------- + + +class AlertManager: + """Rule-based alerting for skill performance monitoring. + + Parameters + ---------- + registry : SkillRegistry + Skill registry for looking up skill metadata. + tracker : QValueTracker + Tracker for Q-values and outcome statistics. + health_monitor : HealthMonitor | None + Optional health monitor for health-score based rules. + db_path : str | Path | None + SQLite database path. Defaults to ``~/.skillforge/alerts.db``. + """ + + def __init__( + self, + registry: SkillRegistry, + tracker: QValueTracker, + health_monitor: HealthMonitor | None = None, + db_path: str | Path | None = None, + ) -> None: + self._registry = registry + self._tracker = tracker + self._health_monitor = health_monitor + self._db_path = str(db_path or Path.home() / ".skillforge" / "alerts.db") + Path(self._db_path).parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(self._db_path) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._ensure_tables() + + # In-memory state + self._rules: dict[str, AlertRule] = {} + self._callbacks: list[Callable[[Alert], None]] = [] + self._load_rules() + + # ------------------------------------------------------------------ + # Schema + # ------------------------------------------------------------------ + + def _ensure_tables(self) -> None: + self._conn.executescript( + """ + CREATE TABLE IF NOT EXISTS alert_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + rule_type TEXT NOT NULL DEFAULT 'threshold', + skill_id TEXT, + metric TEXT NOT NULL DEFAULT 'health_score', + threshold REAL NOT NULL DEFAULT 0.3, + severity TEXT NOT NULL DEFAULT 'warning', + cooldown_minutes INTEGER NOT NULL DEFAULT 60, + enabled INTEGER NOT NULL DEFAULT 1, + description TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS alerts ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL, + rule_name TEXT NOT NULL DEFAULT '', + skill_id TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + status TEXT NOT NULL DEFAULT 'active', + message TEXT NOT NULL DEFAULT '', + metric_value REAL NOT NULL DEFAULT 0.0, + threshold REAL NOT NULL DEFAULT 0.0, + created_at TEXT NOT NULL, + acknowledged_at TEXT, + resolved_at TEXT, + metadata TEXT NOT NULL DEFAULT '{}' + ); + CREATE INDEX IF NOT EXISTS idx_alerts_status ON alerts(status); + CREATE INDEX IF NOT EXISTS idx_alerts_skill ON alerts(skill_id); + CREATE INDEX IF NOT EXISTS idx_alerts_rule ON alerts(rule_id); + + CREATE TABLE IF NOT EXISTS alert_firings ( + rule_id TEXT NOT NULL, + skill_id TEXT NOT NULL, + fired_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_firings_lookup + ON alert_firings(rule_id, skill_id, fired_at); + """ + ) + self._conn.commit() + + # ------------------------------------------------------------------ + # Rule management + # ------------------------------------------------------------------ + + def _load_rules(self) -> None: + """Load persisted rules from DB into memory.""" + rows = self._conn.execute("SELECT * FROM alert_rules").fetchall() + for row in rows: + d = dict(row) + rule = AlertRule( + id=d["id"], + name=d["name"], + rule_type=AlertRuleType(d["rule_type"]), + skill_id=d["skill_id"], + metric=d["metric"], + threshold=d["threshold"], + severity=AlertSeverity(d["severity"]), + cooldown_minutes=d["cooldown_minutes"], + enabled=bool(d["enabled"]), + description=d["description"], + ) + self._rules[rule.id] = rule + + def add_rule(self, rule: AlertRule) -> AlertRule: + """Register a monitoring rule. + + Parameters + ---------- + rule : AlertRule + The rule to add. If *rule.id* is empty, a UUID is generated. + + Returns + ------- + AlertRule + The persisted rule (with generated ID if needed). + """ + if not rule.id: + rule.id = str(uuid.uuid4()) + + self._rules[rule.id] = rule + self._conn.execute( + "INSERT OR REPLACE INTO alert_rules " + "(id, name, rule_type, skill_id, metric, threshold, severity, " + " cooldown_minutes, enabled, description) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + rule.id, + rule.name, + rule.rule_type.value, + rule.skill_id, + rule.metric, + rule.threshold, + rule.severity.value, + rule.cooldown_minutes, + int(rule.enabled), + rule.description, + ), + ) + self._conn.commit() + logger.debug("Added alert rule '%s' (%s)", rule.name, rule.id) + return rule + + def remove_rule(self, rule_id: str) -> bool: + """Remove a monitoring rule. + + Parameters + ---------- + rule_id : str + The rule's ID. + + Returns + ------- + bool + ``True`` if the rule was found and removed. + """ + if rule_id not in self._rules: + return False + del self._rules[rule_id] + self._conn.execute("DELETE FROM alert_rules WHERE id = ?", (rule_id,)) + self._conn.commit() + logger.debug("Removed alert rule '%s'", rule_id) + return True + + def get_rules(self) -> list[AlertRule]: + """Return all registered rules. + + Returns + ------- + list[AlertRule] + All rules (enabled and disabled). + """ + return list(self._rules.values()) + + def get_rule(self, rule_id: str) -> AlertRule | None: + """Return a single rule by ID. + + Parameters + ---------- + rule_id : str + The rule's ID. + + Returns + ------- + AlertRule | None + The rule, or ``None`` if not found. + """ + return self._rules.get(rule_id) + + # ------------------------------------------------------------------ + # Alert checking + # ------------------------------------------------------------------ + + def check_alerts(self) -> list[Alert]: + """Evaluate all enabled rules and fire new alerts. + + For each enabled rule, the relevant skills are checked against + the rule's condition. Cooldown is enforced to prevent alert spam. + + Returns + ------- + list[Alert] + Newly fired alerts (empty if no rules triggered). + """ + new_alerts: list[Alert] = [] + now = datetime.now(timezone.utc) + + for rule in self._rules.values(): + if not rule.enabled: + continue + + skill_ids = self._get_target_skills(rule) + for sid in skill_ids: + if self._is_in_cooldown(rule.id, sid, rule.cooldown_minutes, now): + continue + + metric_value = self._evaluate_metric(rule, sid) + triggered = self._check_trigger(rule, metric_value) + + if triggered: + alert = self._fire_alert(rule, sid, metric_value, now) + new_alerts.append(alert) + + # Record firing for cooldown tracking + self._conn.execute( + "INSERT INTO alert_firings (rule_id, skill_id, fired_at) " + "VALUES (?,?,?)", + (rule.id, sid, now.isoformat()), + ) + self._conn.commit() + + # Invoke callbacks + for cb in self._callbacks: + try: + cb(alert) + except Exception: + logger.exception("Alert callback error for rule '%s'", rule.name) + + return new_alerts + + def _get_target_skills(self, rule: AlertRule) -> list[str]: + """Determine which skills a rule applies to.""" + if rule.skill_id is not None: + return [rule.skill_id] + # All registered skills + skills = self._registry.list_skills(limit=10_000) + return [s.id for s in skills] + + def _is_in_cooldown( + self, + rule_id: str, + skill_id: str, + cooldown_minutes: int, + now: datetime, + ) -> bool: + """Check if a rule+skill combo is in cooldown.""" + cutoff = (now - timedelta(minutes=cooldown_minutes)).isoformat() + row = self._conn.execute( + "SELECT COUNT(*) AS cnt FROM alert_firings " + "WHERE rule_id = ? AND skill_id = ? AND fired_at >= ?", + (rule_id, skill_id, cutoff), + ).fetchone() + return (row["cnt"] if row else 0) > 0 + + def _evaluate_metric(self, rule: AlertRule, skill_id: str) -> float: + """Compute the current metric value for a skill. + + Supported metrics: + - ``health_score``: requires a HealthMonitor + - ``q_value``: from the tracker + - ``success_rate``: from the tracker + - ``failure_rate``: 1 - success_rate + - ``usage_count``: from the tracker + """ + metric = rule.metric + + if metric == "health_score": + if self._health_monitor is not None: + try: + report = self._health_monitor.check_health(skill_id) + return report.health_score + except ValueError: + return 0.5 # skill not found + # Fallback: use Q-value as proxy + return self._tracker.get_q_value(skill_id) + + if metric == "q_value": + return self._tracker.get_q_value(skill_id) + + if metric == "success_rate": + return self._tracker.get_success_rate(skill_id) + + if metric == "failure_rate": + return 1.0 - self._tracker.get_success_rate(skill_id) + + if metric == "usage_count": + return float(self._tracker.get_usage_count(skill_id)) + + # Unknown metric + return 0.5 + + def _check_trigger(self, rule: AlertRule, metric_value: float) -> bool: + """Determine if a rule's condition is met. + + - ``threshold``: fires when metric < threshold + - ``trend``: fires when metric < threshold (simplified — would need + history for true trend detection; we use a static threshold here) + - ``anomaly``: fires when metric < threshold (simplified anomaly + detection using a fixed lower bound) + """ + if rule.rule_type == AlertRuleType.THRESHOLD: + return metric_value < rule.threshold + elif rule.rule_type == AlertRuleType.TREND: + return metric_value < rule.threshold + elif rule.rule_type == AlertRuleType.ANOMALY: + return metric_value < rule.threshold + return False + + def _fire_alert( + self, + rule: AlertRule, + skill_id: str, + metric_value: float, + now: datetime, + ) -> Alert: + """Create and persist a new alert.""" + skill = self._registry.get_skill(skill_id, tier=1) + skill_name = skill.name if skill else skill_id + + message = ( + f"[{rule.severity.value.upper()}] Rule '{rule.name}' triggered " + f"for skill '{skill_name}' ({skill_id}): " + f"{rule.metric} = {metric_value:.4f} " + f"(threshold: {rule.threshold:.4f})" + ) + + alert = Alert( + id=str(uuid.uuid4()), + rule_id=rule.id, + rule_name=rule.name, + skill_id=skill_id, + severity=rule.severity, + status=AlertStatus.ACTIVE, + message=message, + metric_value=metric_value, + threshold=rule.threshold, + created_at=now.isoformat(), + metadata={ + "skill_name": skill_name, + "rule_type": rule.rule_type.value, + "metric": rule.metric, + }, + ) + + self._conn.execute( + "INSERT INTO alerts " + "(id, rule_id, rule_name, skill_id, severity, status, message, " + " metric_value, threshold, created_at, acknowledged_at, " + " resolved_at, metadata) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + alert.id, + alert.rule_id, + alert.rule_name, + alert.skill_id, + alert.severity.value, + alert.status.value, + alert.message, + alert.metric_value, + alert.threshold, + alert.created_at, + alert.acknowledged_at, + alert.resolved_at, + json.dumps(alert.metadata), + ), + ) + self._conn.commit() + + logger.warning("Alert fired: %s", alert.message) + return alert + + # ------------------------------------------------------------------ + # Alert lifecycle + # ------------------------------------------------------------------ + + def get_active_alerts( + self, + skill_id: str | None = None, + severity: AlertSeverity | None = None, + limit: int = 50, + ) -> list[Alert]: + """Return currently active alerts. + + Parameters + ---------- + skill_id : str | None + Filter by skill. + severity : AlertSeverity | None + Filter by severity. + limit : int + Maximum results. + + Returns + ------- + list[Alert] + Active alerts sorted by creation time (newest first). + """ + return self._query_alerts( + status=AlertStatus.ACTIVE, + skill_id=skill_id, + severity=severity, + limit=limit, + ) + + def get_alert_history( + self, + skill_id: str | None = None, + status: AlertStatus | None = None, + limit: int = 100, + ) -> list[Alert]: + """Return historical alerts (all statuses). + + Parameters + ---------- + skill_id : str | None + Filter by skill. + status : AlertStatus | None + Filter by status. + limit : int + Maximum results. + + Returns + ------- + list[Alert] + Alerts sorted by creation time (newest first). + """ + return self._query_alerts( + status=status, + skill_id=skill_id, + limit=limit, + ) + + def acknowledge_alert(self, alert_id: str) -> Alert | None: + """Mark an active alert as acknowledged. + + Parameters + ---------- + alert_id : str + The alert's ID. + + Returns + ------- + Alert | None + The updated alert, or ``None`` if not found. + """ + now = self._now_iso() + cur = self._conn.execute( + "UPDATE alerts SET status = ?, acknowledged_at = ? " + "WHERE id = ? AND status = ?", + (AlertStatus.ACKNOWLEDGED.value, now, alert_id, AlertStatus.ACTIVE.value), + ) + self._conn.commit() + if cur.rowcount == 0: + return None + return self._get_alert(alert_id) + + def resolve_alert(self, alert_id: str) -> Alert | None: + """Mark an alert as resolved. + + Parameters + ---------- + alert_id : str + The alert's ID. + + Returns + ------- + Alert | None + The updated alert, or ``None`` if not found. + """ + now = self._now_iso() + cur = self._conn.execute( + "UPDATE alerts SET status = ?, resolved_at = ? " + "WHERE id = ? AND status IN (?, ?)", + ( + AlertStatus.RESOLVED.value, + now, + alert_id, + AlertStatus.ACTIVE.value, + AlertStatus.ACKNOWLEDGED.value, + ), + ) + self._conn.commit() + if cur.rowcount == 0: + return None + return self._get_alert(alert_id) + + # ------------------------------------------------------------------ + # Callbacks + # ------------------------------------------------------------------ + + def register_callback(self, callback: Callable[[Alert], None]) -> None: + """Register a callback to be invoked when a new alert fires. + + Parameters + ---------- + callback : Callable[[Alert], None] + A callable that receives the :class:`Alert` object. + """ + self._callbacks.append(callback) + + def unregister_callback(self, callback: Callable[[Alert], None]) -> bool: + """Remove a previously registered callback. + + Parameters + ---------- + callback : Callable[[Alert], None] + The callback to remove. + + Returns + ------- + bool + ``True`` if the callback was found and removed. + """ + try: + self._callbacks.remove(callback) + return True + except ValueError: + return False + + # ------------------------------------------------------------------ + # Summary / stats + # ------------------------------------------------------------------ + + def get_alert_summary(self) -> dict[str, Any]: + """Return a summary of the alerting system state. + + Returns + ------- + dict[str, Any] + Keys: ``total_rules``, ``enabled_rules``, ``active_alerts``, + ``acknowledged_alerts``, ``resolved_alerts``, ``by_severity``. + """ + enabled = sum(1 for r in self._rules.values() if r.enabled) + + counts: dict[str, int] = {} + for status in AlertStatus: + row = self._conn.execute( + "SELECT COUNT(*) AS cnt FROM alerts WHERE status = ?", + (status.value,), + ).fetchone() + counts[status.value] = row["cnt"] if row else 0 + + by_severity: dict[str, int] = {} + for sev in AlertSeverity: + row = self._conn.execute( + "SELECT COUNT(*) AS cnt FROM alerts WHERE severity = ? AND status = ?", + (sev.value, AlertStatus.ACTIVE.value), + ).fetchone() + by_severity[sev.value] = row["cnt"] if row else 0 + + return { + "total_rules": len(self._rules), + "enabled_rules": enabled, + "active_alerts": counts.get("active", 0), + "acknowledged_alerts": counts.get("acknowledged", 0), + "resolved_alerts": counts.get("resolved", 0), + "by_severity": by_severity, + } + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _query_alerts( + self, + status: AlertStatus | None = None, + skill_id: str | None = None, + severity: AlertSeverity | None = None, + limit: int = 100, + ) -> list[Alert]: + """Generic alert query with optional filters.""" + clauses: list[str] = [] + params: list[Any] = [] + + if status is not None: + clauses.append("status = ?") + params.append(status.value) + if skill_id is not None: + clauses.append("skill_id = ?") + params.append(skill_id) + if severity is not None: + clauses.append("severity = ?") + params.append(severity.value) + + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + params.append(limit) + + rows = self._conn.execute( + f"SELECT * FROM alerts{where} ORDER BY created_at DESC LIMIT ?", + params, + ).fetchall() + return [self._row_to_alert(r) for r in rows] + + def _get_alert(self, alert_id: str) -> Alert | None: + """Fetch a single alert by ID.""" + row = self._conn.execute( + "SELECT * FROM alerts WHERE id = ?", (alert_id,) + ).fetchone() + if row is None: + return None + return self._row_to_alert(row) + + @staticmethod + def _row_to_alert(row: sqlite3.Row) -> Alert: + d = dict(row) + d["severity"] = AlertSeverity(d["severity"]) + d["status"] = AlertStatus(d["status"]) + d["metadata"] = json.loads(d.get("metadata") or "{}") + return Alert(**d) + + @staticmethod + def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """Close the database connection.""" + self._conn.close() diff --git a/skillforge/marketplace/__init__.py b/skillforge/marketplace/__init__.py new file mode 100644 index 0000000..cc8c098 --- /dev/null +++ b/skillforge/marketplace/__init__.py @@ -0,0 +1,21 @@ +"""Skill Marketplace — publish, discover, install, and rate community skills.""" + +from skillforge.marketplace.registry import ( + MarketplaceRegistry, + MarketplaceEntry, + MarketplaceStatus, + InstallRecord, +) +from skillforge.marketplace.publisher import SkillPublisher, PublishResult +from skillforge.marketplace.installer import SkillInstaller, InstallResult + +__all__ = [ + "MarketplaceRegistry", + "MarketplaceEntry", + "MarketplaceStatus", + "InstallRecord", + "SkillPublisher", + "PublishResult", + "SkillInstaller", + "InstallResult", +] diff --git a/skillforge/marketplace/installer.py b/skillforge/marketplace/installer.py new file mode 100644 index 0000000..1a9d8d1 --- /dev/null +++ b/skillforge/marketplace/installer.py @@ -0,0 +1,212 @@ +"""Skill installer — install marketplace skills into a local registry. + +Handles fetching marketplace listings, resolving dependencies, and +importing skills into a :class:`skillforge.core.registry.SkillRegistry`. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from .registry import MarketplaceEntry, MarketplaceRegistry + + +@dataclass +class InstallResult: + """Result of an installation operation. + + Attributes: + success: Whether the install succeeded. + listing_id: Marketplace listing that was installed. + registry_id: ID assigned in the local skill registry. + installed_version: Version that was installed. + errors: List of error messages if the install failed. + installed_at: Timestamp of the install. + """ + + success: bool + listing_id: str = "" + registry_id: str = "" + installed_version: str = "" + errors: list[str] = field(default_factory=list) + installed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +class SkillInstaller: + """Install skills from the marketplace into a local registry. + + Parameters + ---------- + marketplace : MarketplaceRegistry + The marketplace to install from. + """ + + def __init__(self, marketplace: MarketplaceRegistry) -> None: + self._marketplace = marketplace + + def install( + self, + listing_id: str, + registry: Any | None = None, + ) -> InstallResult: + """Install a marketplace skill into a local registry. + + Parameters + ---------- + listing_id : str + Marketplace listing ID to install. + registry : Any | None + Optional local :class:`SkillRegistry` instance. If *None* the + skill is only recorded as installed (no local registry import). + + Returns + ------- + InstallResult + Installation result. + """ + # Fetch listing + entry = self._marketplace.get_listing(listing_id) + if entry is None: + return InstallResult( + success=False, + listing_id=listing_id, + errors=[f"Listing '{listing_id}' not found"], + ) + + from .registry import MarketplaceStatus + + # Allow installing published or unlisted (but not pending/deprecated) + if entry.status not in (MarketplaceStatus.PUBLISHED, MarketplaceStatus.UNLISTED): + return InstallResult( + success=False, + listing_id=listing_id, + errors=[ + f"Listing status is '{entry.status.value}' — " + "only published or unlisted skills can be installed" + ], + ) + + registry_id = "" + try: + if registry is not None: + # Import into local registry + combined_tags = list(entry.tags) + [ + f"marketplace:{listing_id}", + f"publisher:{entry.publisher}", + f"version:{entry.version}", + ] + skill = registry.register_skill( + name=entry.skill_name, + tier1_metadata=entry.tier1_metadata or entry.description, + tier2_core=entry.tier2_core, + tier3_resources=entry.tier3_resources, + tags=combined_tags, + ) + registry_id = skill.id + else: + registry_id = str(uuid.uuid4()) + + # Record the install + self._marketplace.record_install( + listing_id=listing_id, + installed_version=entry.version, + target_registry_id=registry_id, + ) + + return InstallResult( + success=True, + listing_id=listing_id, + registry_id=registry_id, + installed_version=entry.version, + ) + + except Exception as exc: + return InstallResult( + success=False, + listing_id=listing_id, + errors=[f"Installation failed: {exc}"], + ) + + def get_installed_version(self, listing_id: str) -> str | None: + """Get the latest installed version for a listing. + + Parameters + ---------- + listing_id : str + Marketplace listing ID. + + Returns + ------- + str | None + The latest installed version string, or *None* if never installed. + """ + history = self._marketplace.get_install_history(listing_id, limit=1) + if history: + return history[0].installed_version + return None + + def check_update(self, listing_id: str) -> tuple[str | None, str | None]: + """Check if an update is available for an installed skill. + + Parameters + ---------- + listing_id : str + Marketplace listing ID. + + Returns + ------- + tuple[str | None, str | None] + (installed_version, latest_version), or (None, None) if + not installed or listing not found. + """ + installed = self.get_installed_version(listing_id) + if installed is None: + return None, None + + entry = self._marketplace.get_listing(listing_id) + if entry is None: + return None, None + + return installed, entry.version + + def uninstall( + self, + listing_id: str, + registry: Any | None = None, + ) -> bool: + """Uninstall a marketplace skill from the local registry. + + Parameters + ---------- + listing_id : str + Marketplace listing ID to uninstall. + registry : Any | None + Local registry to remove the skill from. + + Returns + ------- + bool + True if uninstall was successful. + """ + if registry is None: + return True + + try: + history = self._marketplace.get_install_history(listing_id, limit=1) + if not history: + return False + + target_id = history[0].target_registry_id + if target_id: + skill = registry.get_skill(target_id, tier=3) + if skill is not None: + registry.update_skill( + target_id, {"lifecycle": "archived"} + ) + return True + return False + except Exception: + return False diff --git a/skillforge/marketplace/publisher.py b/skillforge/marketplace/publisher.py new file mode 100644 index 0000000..655b2d4 --- /dev/null +++ b/skillforge/marketplace/publisher.py @@ -0,0 +1,359 @@ +"""Skill publisher — package and publish skills to the marketplace. + +Wraps :class:`MarketplaceRegistry` to provide a higher-level publish +workflow that validates skills before submission. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from .registry import MarketplaceEntry, MarketplaceRegistry, MarketplaceStatus + + +# Semantic version regex +_SEMVER_RE = re.compile( + r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" + r"(?:-(?P
[0-9A-Za-z\-.]+))?(?:\+(?P[0-9A-Za-z\-.]+))?$"
+)
+
+
+@dataclass
+class PublishResult:
+    """Result of a publish operation.
+
+    Attributes:
+        success: Whether the publish succeeded.
+        entry: The published marketplace entry (on success).
+        errors: List of validation error messages (on failure).
+        warnings: Non-fatal warnings.
+        published_at: Timestamp of publication.
+    """
+
+    success: bool
+    entry: MarketplaceEntry | None = None
+    errors: list[str] = field(default_factory=list)
+    warnings: list[str] = field(default_factory=list)
+    published_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+class SkillPublisher:
+    """High-level publisher that validates and publishes skills.
+
+    Parameters
+    ----------
+    marketplace : MarketplaceRegistry
+        The marketplace registry to publish to.
+    """
+
+    def __init__(self, marketplace: MarketplaceRegistry) -> None:
+        self._marketplace = marketplace
+
+    # ------------------------------------------------------------------
+    # Validation
+    # ------------------------------------------------------------------
+
+    @staticmethod
+    def validate_skill(
+        skill_name: str,
+        publisher: str,
+        version: str = "1.0.0",
+        tier1_metadata: str = "",
+        tier2_core: str = "",
+        tags: list[str] | None = None,
+    ) -> tuple[list[str], list[str]]:
+        """Validate a skill before publishing.
+
+        Parameters
+        ----------
+        skill_name : str
+            Skill name (required, 2–100 chars).
+        publisher : str
+            Publisher name (required, 2–50 chars).
+        version : str
+            Semantic version string.
+        tier1_metadata : str
+            Short routing summary.
+        tier2_core : str
+            Core prompt / instructions.
+        tags : list[str] | None
+            Tags for the listing.
+
+        Returns
+        -------
+        tuple[list[str], list[str]]
+            (errors, warnings) — errors are fatal, warnings are not.
+        """
+        errors: list[str] = []
+        warnings: list[str] = []
+
+        # Name validation
+        if not skill_name or len(skill_name.strip()) < 2:
+            errors.append("Skill name must be at least 2 characters")
+        if len(skill_name) > 100:
+            errors.append("Skill name must be at most 100 characters")
+
+        # Publisher validation
+        if not publisher or len(publisher.strip()) < 2:
+            errors.append("Publisher name must be at least 2 characters")
+        if len(publisher) > 50:
+            errors.append("Publisher name must be at most 50 characters")
+
+        # Version validation
+        if not _SEMVER_RE.match(version):
+            errors.append(
+                f"Invalid semantic version '{version}'. "
+                "Expected format: MAJOR.MINOR.PATCH"
+            )
+
+        # Content validation
+        if not tier1_metadata:
+            warnings.append("tier1_metadata is empty — routing quality may suffer")
+        elif len(tier1_metadata) > 500:
+            warnings.append(
+                f"tier1_metadata is {len(tier1_metadata)} chars — "
+                "recommend under 500 for routing efficiency"
+            )
+
+        if not tier2_core:
+            warnings.append("tier2_core is empty — skill has no instructions")
+
+        # Tag validation
+        if not tags:
+            warnings.append("No tags provided — discoverability may be reduced")
+        elif len(tags) > 20:
+            warnings.append(f"Too many tags ({len(tags)}) — recommend at most 20")
+
+        return errors, warnings
+
+    # ------------------------------------------------------------------
+    # Publishing
+    # ------------------------------------------------------------------
+
+    def publish_skill(
+        self,
+        skill_name: str,
+        publisher: str,
+        description: str = "",
+        version: str = "1.0.0",
+        tier1_metadata: str = "",
+        tier2_core: str = "",
+        tier3_resources: list[str] | None = None,
+        tags: list[str] | None = None,
+        license: str = "MIT",
+        listing_id: str | None = None,
+    ) -> PublishResult:
+        """Validate and publish a skill to the marketplace.
+
+        Parameters
+        ----------
+        skill_name : str
+            Human-readable skill name.
+        publisher : str
+            Author / publisher name.
+        description : str
+            Short description for the listing.
+        version : str
+            Semantic version string.
+        tier1_metadata : str
+            Routing metadata (~30 tokens).
+        tier2_core : str
+            Core prompt / instructions.
+        tier3_resources : list[str] | None
+            Auxiliary resource paths.
+        tags : list[str] | None
+            Searchable tags.
+        license : str
+            License identifier.
+        listing_id : str | None
+            Explicit listing ID.  A UUID is generated when *None*.
+
+        Returns
+        -------
+        PublishResult
+            Result containing the entry on success, or errors on failure.
+        """
+        errors, warnings = self.validate_skill(
+            skill_name=skill_name,
+            publisher=publisher,
+            version=version,
+            tier1_metadata=tier1_metadata,
+            tier2_core=tier2_core,
+            tags=tags,
+        )
+
+        if errors:
+            return PublishResult(success=False, errors=errors, warnings=warnings)
+
+        try:
+            entry = self._marketplace.publish(
+                skill_name=skill_name,
+                publisher=publisher,
+                description=description,
+                version=version,
+                tier1_metadata=tier1_metadata,
+                tier2_core=tier2_core,
+                tier3_resources=tier3_resources,
+                tags=tags,
+                license=license,
+                listing_id=listing_id,
+                status=MarketplaceStatus.PUBLISHED,
+            )
+            return PublishResult(success=True, entry=entry, warnings=warnings)
+        except ValueError as exc:
+            return PublishResult(
+                success=False,
+                errors=[str(exc)],
+                warnings=warnings,
+            )
+
+    def update_published_skill(
+        self,
+        listing_id: str,
+        updates: dict[str, Any],
+    ) -> PublishResult:
+        """Update an existing published listing.
+
+        Parameters
+        ----------
+        listing_id : str
+            Listing to update.
+        updates : dict
+            Fields to update.
+
+        Returns
+        -------
+        PublishResult
+            Result of the update operation.
+        """
+        # If version is being updated, validate it
+        if "version" in updates:
+            if not _SEMVER_RE.match(updates["version"]):
+                return PublishResult(
+                    success=False,
+                    errors=[
+                        f"Invalid semantic version '{updates['version']}'. "
+                        "Expected format: MAJOR.MINOR.PATCH"
+                    ],
+                )
+
+        try:
+            entry = self._marketplace.update_listing(listing_id, updates)
+            if entry is None:
+                return PublishResult(
+                    success=False,
+                    errors=[f"Listing '{listing_id}' not found"],
+                )
+            return PublishResult(success=True, entry=entry)
+        except Exception as exc:
+            return PublishResult(success=False, errors=[str(exc)])
+
+    def deprecate_skill(self, listing_id: str) -> bool:
+        """Deprecate a published skill.
+
+        Parameters
+        ----------
+        listing_id : str
+            Listing to deprecate.
+
+        Returns
+        -------
+        bool
+            True if deprecation succeeded.
+        """
+        entry = self._marketplace.deprecate_listing(listing_id)
+        return entry is not None
+
+    # ------------------------------------------------------------------
+    # Packaging
+    # ------------------------------------------------------------------
+
+    @staticmethod
+    def package_skill(
+        skill_name: str,
+        tier1_metadata: str,
+        tier2_core: str,
+        tier3_resources: list[str] | None = None,
+        tags: list[str] | None = None,
+        version: str = "1.0.0",
+    ) -> dict[str, Any]:
+        """Package a skill into a portable dictionary format.
+
+        Parameters
+        ----------
+        skill_name : str
+            Skill name.
+        tier1_metadata : str
+            Routing metadata.
+        tier2_core : str
+            Core instructions.
+        tier3_resources : list[str] | None
+            Resource list.
+        tags : list[str] | None
+            Tags.
+        version : str
+            Semantic version.
+
+        Returns
+        -------
+        dict[str, Any]
+            Portable skill package dictionary.
+        """
+        return {
+            "format": "skillforge-package",
+            "format_version": 1,
+            "skill_name": skill_name,
+            "version": version,
+            "tier1_metadata": tier1_metadata,
+            "tier2_core": tier2_core,
+            "tier3_resources": tier3_resources or [],
+            "tags": tags or [],
+        }
+
+    @staticmethod
+    def serialize_package(package: dict[str, Any]) -> str:
+        """Serialize a skill package to a JSON string.
+
+        Parameters
+        ----------
+        package : dict[str, Any]
+            Skill package dictionary.
+
+        Returns
+        -------
+        str
+            JSON string representation.
+        """
+        return json.dumps(package, indent=2, ensure_ascii=False)
+
+    @staticmethod
+    def deserialize_package(data: str) -> dict[str, Any]:
+        """Deserialize a JSON string into a skill package.
+
+        Parameters
+        ----------
+        data : str
+            JSON string.
+
+        Returns
+        -------
+        dict[str, Any]
+            Skill package dictionary.
+
+        Raises
+        ------
+        ValueError
+            If the data is not a valid skill package.
+        """
+        package = json.loads(data)
+        if not isinstance(package, dict):
+            raise ValueError("Package must be a JSON object")
+        if package.get("format") != "skillforge-package":
+            raise ValueError(
+                f"Unknown package format: {package.get('format')!r}"
+            )
+        return package
diff --git a/skillforge/marketplace/registry.py b/skillforge/marketplace/registry.py
new file mode 100644
index 0000000..a72b9f1
--- /dev/null
+++ b/skillforge/marketplace/registry.py
@@ -0,0 +1,631 @@
+"""Marketplace registry — persistent store for published, discoverable skills.
+
+Provides a SQLite-backed catalogue where skills can be published, searched,
+rated, and tracked for install counts.  Mirrors the structure of
+:class:`skillforge.core.registry.SkillRegistry` for consistency.
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+
+class MarketplaceStatus(Enum):
+    """Publication status of a marketplace entry."""
+
+    PENDING = "pending"
+    PUBLISHED = "published"
+    UNLISTED = "unlisted"
+    DEPRECATED = "deprecated"
+
+
+@dataclass
+class MarketplaceEntry:
+    """A skill listing in the marketplace.
+
+    Attributes:
+        id: Unique listing identifier.
+        skill_name: Human-readable skill name.
+        publisher: Publisher / author name.
+        description: Short summary (~100 words).
+        version: Semantic version string (e.g. ``"1.2.0"``).
+        tags: List of searchable tags.
+        status: Current publication status.
+        tier1_metadata: Short metadata for routing.
+        tier2_core: Full core prompt / instructions.
+        tier3_resources: Auxiliary resource paths or URLs.
+        install_count: Number of times this skill has been installed.
+        rating: Average user rating (0.0–5.0).
+        rating_count: Number of ratings submitted.
+        license: License identifier (e.g. ``"MIT"``).
+        created_at: When the listing was first created.
+        updated_at: When the listing was last updated.
+    """
+
+    id: str
+    skill_name: str
+    publisher: str
+    description: str = ""
+    version: str = "1.0.0"
+    tags: list[str] = field(default_factory=list)
+    status: MarketplaceStatus = MarketplaceStatus.PENDING
+    tier1_metadata: str = ""
+    tier2_core: str = ""
+    tier3_resources: list[str] = field(default_factory=list)
+    install_count: int = 0
+    rating: float = 0.0
+    rating_count: int = 0
+    license: str = "MIT"
+    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+    updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+@dataclass
+class InstallRecord:
+    """Record of a skill installation from the marketplace.
+
+    Attributes:
+        id: Unique record identifier.
+        listing_id: Marketplace listing ID.
+        installed_at: When the install happened.
+        installed_version: Version that was installed.
+        target_registry_id: ID assigned in the local registry.
+    """
+
+    id: str
+    listing_id: str
+    installed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+    installed_version: str = "1.0.0"
+    target_registry_id: str = ""
+
+
+class MarketplaceRegistry:
+    """Persistent marketplace catalogue backed by SQLite.
+
+    Parameters
+    ----------
+    db_path : str | Path | None
+        Path to the SQLite database file.
+        Defaults to ``~/.skillforge/marketplace.db``.
+    """
+
+    def __init__(self, db_path: str | Path | None = None) -> None:
+        self._db_path = str(db_path or Path.home() / ".skillforge" / "marketplace.db")
+        Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
+        self._conn = sqlite3.connect(self._db_path)
+        self._conn.row_factory = sqlite3.Row
+        self._conn.execute("PRAGMA journal_mode=WAL")
+        self._conn.execute("PRAGMA foreign_keys=ON")
+        self._ensure_tables()
+
+    # ------------------------------------------------------------------
+    # Schema
+    # ------------------------------------------------------------------
+
+    def _ensure_tables(self) -> None:
+        """Create marketplace tables if they do not exist."""
+        self._conn.executescript(
+            """
+            CREATE TABLE IF NOT EXISTS marketplace_listings (
+                id              TEXT PRIMARY KEY,
+                skill_name      TEXT NOT NULL,
+                publisher       TEXT NOT NULL,
+                description     TEXT NOT NULL DEFAULT '',
+                version         TEXT NOT NULL DEFAULT '1.0.0',
+                tags            TEXT NOT NULL DEFAULT '[]',
+                status          TEXT NOT NULL DEFAULT 'pending',
+                tier1_metadata  TEXT NOT NULL DEFAULT '',
+                tier2_core      TEXT NOT NULL DEFAULT '',
+                tier3_resources TEXT NOT NULL DEFAULT '[]',
+                install_count   INTEGER NOT NULL DEFAULT 0,
+                rating          REAL NOT NULL DEFAULT 0.0,
+                rating_count    INTEGER NOT NULL DEFAULT 0,
+                license         TEXT NOT NULL DEFAULT 'MIT',
+                created_at      TEXT NOT NULL,
+                updated_at      TEXT NOT NULL
+            );
+            CREATE INDEX IF NOT EXISTS idx_mp_status
+                ON marketplace_listings(status);
+            CREATE INDEX IF NOT EXISTS idx_mp_rating
+                ON marketplace_listings(rating DESC);
+
+            CREATE TABLE IF NOT EXISTS marketplace_installs (
+                id                  TEXT PRIMARY KEY,
+                listing_id          TEXT NOT NULL,
+                installed_at        TEXT NOT NULL,
+                installed_version   TEXT NOT NULL DEFAULT '1.0.0',
+                target_registry_id  TEXT NOT NULL DEFAULT '',
+                FOREIGN KEY (listing_id) REFERENCES marketplace_listings(id)
+            );
+            CREATE INDEX IF NOT EXISTS idx_mpi_listing
+                ON marketplace_installs(listing_id);
+            """
+        )
+        self._conn.commit()
+
+    # ------------------------------------------------------------------
+    # Helpers
+    # ------------------------------------------------------------------
+
+    @staticmethod
+    def _row_to_entry(row: sqlite3.Row) -> MarketplaceEntry:
+        """Convert a database row to a MarketplaceEntry."""
+        d = dict(row)
+        d["status"] = MarketplaceStatus(d["status"])
+        d["tags"] = json.loads(d["tags"])
+        d["tier3_resources"] = json.loads(d["tier3_resources"])
+        d["created_at"] = datetime.fromisoformat(d["created_at"])
+        d["updated_at"] = datetime.fromisoformat(d["updated_at"])
+        return MarketplaceEntry(**d)
+
+    @staticmethod
+    def _entry_to_params(e: MarketplaceEntry) -> dict[str, Any]:
+        """Convert a MarketplaceEntry to database parameters."""
+        return {
+            "id": e.id,
+            "skill_name": e.skill_name,
+            "publisher": e.publisher,
+            "description": e.description,
+            "version": e.version,
+            "tags": json.dumps(e.tags),
+            "status": e.status.value,
+            "tier1_metadata": e.tier1_metadata,
+            "tier2_core": e.tier2_core,
+            "tier3_resources": json.dumps(e.tier3_resources),
+            "install_count": e.install_count,
+            "rating": e.rating,
+            "rating_count": e.rating_count,
+            "license": e.license,
+            "created_at": e.created_at.isoformat(),
+            "updated_at": e.updated_at.isoformat(),
+        }
+
+    # ------------------------------------------------------------------
+    # CRUD — Listings
+    # ------------------------------------------------------------------
+
+    def publish(
+        self,
+        skill_name: str,
+        publisher: str,
+        description: str = "",
+        version: str = "1.0.0",
+        tier1_metadata: str = "",
+        tier2_core: str = "",
+        tier3_resources: list[str] | None = None,
+        tags: list[str] | None = None,
+        license: str = "MIT",
+        listing_id: str | None = None,
+        status: MarketplaceStatus = MarketplaceStatus.PUBLISHED,
+    ) -> MarketplaceEntry:
+        """Publish a new skill to the marketplace.
+
+        Parameters
+        ----------
+        skill_name : str
+            Human-readable skill name.
+        publisher : str
+            Author / publisher name.
+        description : str
+            Short description (~100 words).
+        version : str
+            Semantic version string.
+        tier1_metadata : str
+            Routing summary (~30 tokens).
+        tier2_core : str
+            Full core prompt / instructions.
+        tier3_resources : list[str] | None
+            Auxiliary resource list.
+        tags : list[str] | None
+            Searchable tags.
+        license : str
+            License identifier.
+        listing_id : str | None
+            Explicit listing ID.  A UUID is generated when *None*.
+        status : MarketplaceStatus
+            Initial publication status.
+
+        Returns
+        -------
+        MarketplaceEntry
+            The newly created listing.
+
+        Raises
+        ------
+        ValueError
+            If a listing with the given *listing_id* already exists.
+        """
+        lid = listing_id or str(uuid.uuid4())
+        now = datetime.now(timezone.utc)
+        entry = MarketplaceEntry(
+            id=lid,
+            skill_name=skill_name,
+            publisher=publisher,
+            description=description,
+            version=version,
+            tags=tags or [],
+            status=status,
+            tier1_metadata=tier1_metadata,
+            tier2_core=tier2_core,
+            tier3_resources=tier3_resources or [],
+            license=license,
+            created_at=now,
+            updated_at=now,
+        )
+        try:
+            self._conn.execute(
+                "INSERT INTO marketplace_listings VALUES "
+                "(:id,:skill_name,:publisher,:description,:version,:tags,:status,"
+                ":tier1_metadata,:tier2_core,:tier3_resources,:install_count,"
+                ":rating,:rating_count,:license,:created_at,:updated_at)",
+                self._entry_to_params(entry),
+            )
+            self._conn.commit()
+        except sqlite3.IntegrityError as exc:
+            raise ValueError(f"Listing '{lid}' already exists") from exc
+        return entry
+
+    def get_listing(self, listing_id: str) -> MarketplaceEntry | None:
+        """Retrieve a marketplace listing by ID.
+
+        Parameters
+        ----------
+        listing_id : str
+            The listing identifier.
+
+        Returns
+        -------
+        MarketplaceEntry | None
+            The listing, or *None* if not found.
+        """
+        row = self._conn.execute(
+            "SELECT * FROM marketplace_listings WHERE id = ?", (listing_id,)
+        ).fetchone()
+        if row is None:
+            return None
+        return self._row_to_entry(row)
+
+    def update_listing(
+        self, listing_id: str, updates: dict[str, Any]
+    ) -> MarketplaceEntry | None:
+        """Apply partial updates to a listing.
+
+        Parameters
+        ----------
+        listing_id : str
+            Listing to update.
+        updates : dict
+            Key-value pairs of fields to change.
+
+        Returns
+        -------
+        MarketplaceEntry | None
+            Updated listing, or *None* if not found.
+        """
+        allowed = {
+            "skill_name", "publisher", "description", "version",
+            "tags", "status", "tier1_metadata", "tier2_core",
+            "tier3_resources", "rating", "rating_count", "license",
+        }
+        filtered = {k: v for k, v in updates.items() if k in allowed}
+        if not filtered:
+            return self.get_listing(listing_id)
+
+        if "tags" in filtered:
+            filtered["tags"] = json.dumps(filtered["tags"])
+        if "tier3_resources" in filtered:
+            filtered["tier3_resources"] = json.dumps(filtered["tier3_resources"])
+        if "status" in filtered and isinstance(filtered["status"], MarketplaceStatus):
+            filtered["status"] = filtered["status"].value
+
+        filtered["updated_at"] = datetime.now(timezone.utc).isoformat()
+        set_clause = ", ".join(f"{k} = :{k}" for k in filtered)
+        filtered["id"] = listing_id
+        cur = self._conn.execute(
+            f"UPDATE marketplace_listings SET {set_clause} WHERE id = :id",
+            filtered,
+        )
+        self._conn.commit()
+        if cur.rowcount == 0:
+            return None
+        return self.get_listing(listing_id)
+
+    def list_listings(
+        self,
+        status: MarketplaceStatus | None = None,
+        tags: list[str] | None = None,
+        publisher: str | None = None,
+        sort_by: str = "rating",
+        limit: int = 50,
+        offset: int = 0,
+    ) -> list[MarketplaceEntry]:
+        """List marketplace listings with optional filters.
+
+        Parameters
+        ----------
+        status : MarketplaceStatus | None
+            Filter by publication status.
+        tags : list[str] | None
+            Filter listings that have at least one of these tags.
+        publisher : str | None
+            Filter by publisher name.
+        sort_by : str
+            Sort field: ``"rating"``, ``"install_count"``, or ``"created_at"``.
+        limit : int
+            Maximum results.
+        offset : int
+            Pagination offset.
+
+        Returns
+        -------
+        list[MarketplaceEntry]
+            Matching listings sorted as requested.
+        """
+        clauses: list[str] = []
+        params: dict[str, Any] = {}
+
+        if status is not None:
+            clauses.append("status = :status")
+            params["status"] = status.value
+        if publisher is not None:
+            clauses.append("publisher = :publisher")
+            params["publisher"] = publisher
+
+        where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
+
+        valid_sorts = {"rating", "install_count", "created_at"}
+        order_col = sort_by if sort_by in valid_sorts else "rating"
+        order_dir = "DESC" if order_col in ("rating", "install_count") else "ASC"
+
+        params["limit"] = limit
+        params["offset"] = offset
+
+        rows = self._conn.execute(
+            f"SELECT * FROM marketplace_listings{where} "
+            f"ORDER BY {order_col} {order_dir} LIMIT :limit OFFSET :offset",
+            params,
+        ).fetchall()
+
+        entries = [self._row_to_entry(r) for r in rows]
+
+        # Post-filter by tags (JSON in SQLite is cumbersome)
+        if tags:
+            tag_set = set(tags)
+            entries = [e for e in entries if tag_set.intersection(e.tags)]
+
+        return entries
+
+    def search(
+        self, query: str, limit: int = 20
+    ) -> list[MarketplaceEntry]:
+        """Keyword search across skill name, description, and tags.
+
+        Parameters
+        ----------
+        query : str
+            Search string.
+        limit : int
+            Maximum results.
+
+        Returns
+        -------
+        list[MarketplaceEntry]
+            Matching listings ranked by rating descending.
+        """
+        pattern = f"%{query}%"
+        rows = self._conn.execute(
+            "SELECT * FROM marketplace_listings "
+            "WHERE skill_name LIKE :q OR description LIKE :q "
+            "OR tier1_metadata LIKE :q "
+            "ORDER BY rating DESC LIMIT :limit",
+            {"q": pattern, "limit": limit},
+        ).fetchall()
+        return [self._row_to_entry(r) for r in rows]
+
+    # ------------------------------------------------------------------
+    # Ratings
+    # ------------------------------------------------------------------
+
+    def rate_listing(
+        self, listing_id: str, user_rating: float
+    ) -> MarketplaceEntry | None:
+        """Submit a rating for a listing (0.0–5.0).
+
+        Updates the rolling average and count.
+
+        Parameters
+        ----------
+        listing_id : str
+            Listing to rate.
+        user_rating : float
+            Rating value, clamped to [0.0, 5.0].
+
+        Returns
+        -------
+        MarketplaceEntry | None
+            Updated listing, or *None* if not found.
+        """
+        user_rating = max(0.0, min(5.0, user_rating))
+        entry = self.get_listing(listing_id)
+        if entry is None:
+            return None
+
+        new_count = entry.rating_count + 1
+        new_avg = (
+            (entry.rating * entry.rating_count + user_rating) / new_count
+        )
+        return self.update_listing(
+            listing_id,
+            {"rating": round(new_avg, 2), "rating_count": new_count},
+        )
+
+    # ------------------------------------------------------------------
+    # Install tracking
+    # ------------------------------------------------------------------
+
+    def record_install(
+        self,
+        listing_id: str,
+        installed_version: str = "1.0.0",
+        target_registry_id: str = "",
+    ) -> InstallRecord:
+        """Record that a listing was installed and bump the install count.
+
+        Parameters
+        ----------
+        listing_id : str
+            The marketplace listing that was installed.
+        installed_version : str
+            Version string that was installed.
+        target_registry_id : str
+            ID assigned to the skill in the local registry.
+
+        Returns
+        -------
+        InstallRecord
+            The persisted install record.
+        """
+        record_id = str(uuid.uuid4())
+        now = datetime.now(timezone.utc)
+
+        record = InstallRecord(
+            id=record_id,
+            listing_id=listing_id,
+            installed_at=now,
+            installed_version=installed_version,
+            target_registry_id=target_registry_id,
+        )
+
+        self._conn.execute(
+            "INSERT INTO marketplace_installs VALUES (:id,:listing_id,"
+            ":installed_at,:installed_version,:target_registry_id)",
+            {
+                "id": record.id,
+                "listing_id": record.listing_id,
+                "installed_at": record.installed_at.isoformat(),
+                "installed_version": record.installed_version,
+                "target_registry_id": record.target_registry_id,
+            },
+        )
+
+        # Bump install count on the listing
+        self._conn.execute(
+            "UPDATE marketplace_listings SET install_count = install_count + 1, "
+            "updated_at = :now WHERE id = :lid",
+            {"now": now.isoformat(), "lid": listing_id},
+        )
+        self._conn.commit()
+        return record
+
+    def get_install_history(
+        self, listing_id: str, limit: int = 50
+    ) -> list[InstallRecord]:
+        """Get installation history for a listing.
+
+        Parameters
+        ----------
+        listing_id : str
+            Listing to query.
+        limit : int
+            Maximum records to return.
+
+        Returns
+        -------
+        list[InstallRecord]
+            Install records, most recent first.
+        """
+        rows = self._conn.execute(
+            "SELECT * FROM marketplace_installs WHERE listing_id = ? "
+            "ORDER BY installed_at DESC LIMIT ?",
+            (listing_id, limit),
+        ).fetchall()
+
+        results: list[InstallRecord] = []
+        for r in rows:
+            d = dict(r)
+            d["installed_at"] = datetime.fromisoformat(d["installed_at"])
+            results.append(InstallRecord(**d))
+        return results
+
+    def get_install_count(self, listing_id: str) -> int:
+        """Return the current install count for a listing.
+
+        Parameters
+        ----------
+        listing_id : str
+            Listing to query.
+
+        Returns
+        -------
+        int
+            Install count, or 0 if listing not found.
+        """
+        row = self._conn.execute(
+            "SELECT install_count FROM marketplace_listings WHERE id = ?",
+            (listing_id,),
+        ).fetchone()
+        return row["install_count"] if row else 0
+
+    # ------------------------------------------------------------------
+    # Listing count
+    # ------------------------------------------------------------------
+
+    def count(self, status: MarketplaceStatus | None = None) -> int:
+        """Return the number of listings, optionally filtered by status.
+
+        Parameters
+        ----------
+        status : MarketplaceStatus | None
+            If given, only count listings with this status.
+
+        Returns
+        -------
+        int
+            Number of matching listings.
+        """
+        if status is not None:
+            row = self._conn.execute(
+                "SELECT COUNT(*) as cnt FROM marketplace_listings WHERE status = ?",
+                (status.value,),
+            ).fetchone()
+        else:
+            row = self._conn.execute(
+                "SELECT COUNT(*) as cnt FROM marketplace_listings"
+            ).fetchone()
+        return row["cnt"] if row else 0
+
+    # ------------------------------------------------------------------
+    # Deprecation
+    # ------------------------------------------------------------------
+
+    def deprecate_listing(self, listing_id: str) -> MarketplaceEntry | None:
+        """Mark a listing as deprecated.
+
+        Parameters
+        ----------
+        listing_id : str
+            Listing to deprecate.
+
+        Returns
+        -------
+        MarketplaceEntry | None
+            Updated listing, or *None* if not found.
+        """
+        return self.update_listing(
+            listing_id, {"status": MarketplaceStatus.DEPRECATED}
+        )
+
+    # ------------------------------------------------------------------
+    # Cleanup
+    # ------------------------------------------------------------------
+
+    def close(self) -> None:
+        """Close the database connection."""
+        self._conn.close()
diff --git a/skillforge/observability/__init__.py b/skillforge/observability/__init__.py
new file mode 100644
index 0000000..8dc7e36
--- /dev/null
+++ b/skillforge/observability/__init__.py
@@ -0,0 +1,31 @@
+"""SkillForge Observability — tracing, metrics, and structured logging."""
+
+from skillforge.observability.tracer import (
+    SkillTracer,
+    Span,
+    SpanStatus,
+    TraceContext,
+)
+from skillforge.observability.metrics import (
+    MetricsCollector,
+    MetricPoint,
+    MetricSummary,
+)
+from skillforge.observability.logger import (
+    StructuredLogger,
+    LogEntry,
+    LogLevel,
+)
+
+__all__ = [
+    "SkillTracer",
+    "Span",
+    "SpanStatus",
+    "TraceContext",
+    "MetricsCollector",
+    "MetricPoint",
+    "MetricSummary",
+    "StructuredLogger",
+    "LogEntry",
+    "LogLevel",
+]
diff --git a/skillforge/observability/logger.py b/skillforge/observability/logger.py
new file mode 100644
index 0000000..6471a22
--- /dev/null
+++ b/skillforge/observability/logger.py
@@ -0,0 +1,423 @@
+"""Structured logger — JSON-structured log entries with SQLite persistence.
+
+Provides structured, machine-parseable log entries that can be queried,
+filtered, and analysed.  Designed to complement standard ``logging``.
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+
+class LogLevel(Enum):
+    """Structured log severity levels."""
+
+    DEBUG = "debug"
+    INFO = "info"
+    WARNING = "warning"
+    ERROR = "error"
+    CRITICAL = "critical"
+
+
+@dataclass
+class LogEntry:
+    """A single structured log entry.
+
+    Attributes:
+        id: Unique entry identifier.
+        level: Severity level.
+        message: Human-readable log message.
+        component: The component that generated the log.
+        skill_id: Related skill (may be empty).
+        trace_id: Optional trace ID for correlation.
+        span_id: Optional span ID for correlation.
+        metadata: Arbitrary key-value metadata.
+        timestamp: When the log entry was created.
+    """
+
+    id: str
+    level: LogLevel
+    message: str
+    component: str = ""
+    skill_id: str = ""
+    trace_id: str = ""
+    span_id: str = ""
+    metadata: dict[str, Any] = field(default_factory=dict)
+    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+    def to_dict(self) -> dict[str, Any]:
+        """Serialize the entry to a dictionary.
+
+        Returns
+        -------
+        dict[str, Any]
+            Dictionary representation suitable for JSON encoding.
+        """
+        return {
+            "id": self.id,
+            "level": self.level.value,
+            "message": self.message,
+            "component": self.component,
+            "skill_id": self.skill_id,
+            "trace_id": self.trace_id,
+            "span_id": self.span_id,
+            "metadata": self.metadata,
+            "timestamp": self.timestamp.isoformat(),
+        }
+
+    def to_json(self) -> str:
+        """Serialize the entry to a JSON string.
+
+        Returns
+        -------
+        str
+            JSON string.
+        """
+        return json.dumps(self.to_dict(), ensure_ascii=False)
+
+
+class StructuredLogger:
+    """Structured logger with SQLite persistence.
+
+    Provides structured, filterable log entries that are stored in a
+    database for later analysis and correlation with traces.
+
+    Parameters
+    ----------
+    db_path : str | Path | None
+        Path to the SQLite database.
+        Defaults to ``~/.skillforge/logs.db``.
+    """
+
+    def __init__(self, db_path: str | Path | None = None) -> None:
+        self._db_path = str(db_path or Path.home() / ".skillforge" / "logs.db")
+        Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
+        self._conn = sqlite3.connect(self._db_path)
+        self._conn.row_factory = sqlite3.Row
+        self._conn.execute("PRAGMA journal_mode=WAL")
+        self._ensure_tables()
+
+    # ------------------------------------------------------------------
+    # Schema
+    # ------------------------------------------------------------------
+
+    def _ensure_tables(self) -> None:
+        """Create log tables if they do not exist."""
+        self._conn.executescript(
+            """
+            CREATE TABLE IF NOT EXISTS log_entries (
+                id          TEXT PRIMARY KEY,
+                level       TEXT NOT NULL,
+                message     TEXT NOT NULL,
+                component   TEXT NOT NULL DEFAULT '',
+                skill_id    TEXT NOT NULL DEFAULT '',
+                trace_id    TEXT NOT NULL DEFAULT '',
+                span_id     TEXT NOT NULL DEFAULT '',
+                metadata    TEXT NOT NULL DEFAULT '{}',
+                timestamp   TEXT NOT NULL
+            );
+            CREATE INDEX IF NOT EXISTS idx_log_level
+                ON log_entries(level);
+            CREATE INDEX IF NOT EXISTS idx_log_component
+                ON log_entries(component);
+            CREATE INDEX IF NOT EXISTS idx_log_skill
+                ON log_entries(skill_id);
+            CREATE INDEX IF NOT EXISTS idx_log_trace
+                ON log_entries(trace_id);
+            CREATE INDEX IF NOT EXISTS idx_log_ts
+                ON log_entries(timestamp);
+            """
+        )
+        self._conn.commit()
+
+    # ------------------------------------------------------------------
+    # Logging
+    # ------------------------------------------------------------------
+
+    def _write(self, entry: LogEntry) -> LogEntry:
+        """Write a log entry to the database.
+
+        Parameters
+        ----------
+        entry : LogEntry
+            Entry to persist.
+
+        Returns
+        -------
+        LogEntry
+            The persisted entry.
+        """
+        self._conn.execute(
+            "INSERT INTO log_entries VALUES "
+            "(:id,:level,:message,:component,:skill_id,:trace_id,"
+            ":span_id,:metadata,:timestamp)",
+            {
+                "id": entry.id,
+                "level": entry.level.value,
+                "message": entry.message,
+                "component": entry.component,
+                "skill_id": entry.skill_id,
+                "trace_id": entry.trace_id,
+                "span_id": entry.span_id,
+                "metadata": json.dumps(entry.metadata),
+                "timestamp": entry.timestamp.isoformat(),
+            },
+        )
+        self._conn.commit()
+        return entry
+
+    def log(
+        self,
+        level: LogLevel,
+        message: str,
+        component: str = "",
+        skill_id: str = "",
+        trace_id: str = "",
+        span_id: str = "",
+        metadata: dict[str, Any] | None = None,
+    ) -> LogEntry:
+        """Record a log entry at the specified level.
+
+        Parameters
+        ----------
+        level : LogLevel
+            Severity level.
+        message : str
+            Log message.
+        component : str
+            Source component.
+        skill_id : str
+            Related skill ID.
+        trace_id : str
+            Trace ID for correlation.
+        span_id : str
+            Span ID for correlation.
+        metadata : dict[str, Any] | None
+            Additional metadata.
+
+        Returns
+        -------
+        LogEntry
+            The persisted log entry.
+        """
+        entry = LogEntry(
+            id=str(uuid.uuid4()),
+            level=level,
+            message=message,
+            component=component,
+            skill_id=skill_id,
+            trace_id=trace_id,
+            span_id=span_id,
+            metadata=metadata or {},
+        )
+        return self._write(entry)
+
+    def debug(
+        self, message: str, component: str = "", skill_id: str = "",
+        metadata: dict[str, Any] | None = None, **kwargs: Any,
+    ) -> LogEntry:
+        """Log a debug message."""
+        return self.log(LogLevel.DEBUG, message, component, skill_id, metadata=metadata, **kwargs)
+
+    def info(
+        self, message: str, component: str = "", skill_id: str = "",
+        metadata: dict[str, Any] | None = None, **kwargs: Any,
+    ) -> LogEntry:
+        """Log an info message."""
+        return self.log(LogLevel.INFO, message, component, skill_id, metadata=metadata, **kwargs)
+
+    def warning(
+        self, message: str, component: str = "", skill_id: str = "",
+        metadata: dict[str, Any] | None = None, **kwargs: Any,
+    ) -> LogEntry:
+        """Log a warning message."""
+        return self.log(LogLevel.WARNING, message, component, skill_id, metadata=metadata, **kwargs)
+
+    def error(
+        self, message: str, component: str = "", skill_id: str = "",
+        metadata: dict[str, Any] | None = None, **kwargs: Any,
+    ) -> LogEntry:
+        """Log an error message."""
+        return self.log(LogLevel.ERROR, message, component, skill_id, metadata=metadata, **kwargs)
+
+    def critical(
+        self, message: str, component: str = "", skill_id: str = "",
+        metadata: dict[str, Any] | None = None, **kwargs: Any,
+    ) -> LogEntry:
+        """Log a critical message."""
+        return self.log(LogLevel.CRITICAL, message, component, skill_id, metadata=metadata, **kwargs)
+
+    # ------------------------------------------------------------------
+    # Queries
+    # ------------------------------------------------------------------
+
+    def get_entries(
+        self,
+        level: LogLevel | None = None,
+        component: str | None = None,
+        skill_id: str | None = None,
+        trace_id: str | None = None,
+        since: datetime | None = None,
+        limit: int = 100,
+    ) -> list[LogEntry]:
+        """Query log entries with optional filters.
+
+        Parameters
+        ----------
+        level : LogLevel | None
+            Filter by severity level.
+        component : str | None
+            Filter by component.
+        skill_id : str | None
+            Filter by skill ID.
+        trace_id : str | None
+            Filter by trace ID.
+        since : datetime | None
+            Only return entries after this time.
+        limit : int
+            Maximum entries to return.
+
+        Returns
+        -------
+        list[LogEntry]
+            Matching entries, most recent first.
+        """
+        clauses: list[str] = []
+        params: list[Any] = []
+
+        if level is not None:
+            clauses.append("level = ?")
+            params.append(level.value)
+        if component is not None:
+            clauses.append("component = ?")
+            params.append(component)
+        if skill_id is not None:
+            clauses.append("skill_id = ?")
+            params.append(skill_id)
+        if trace_id is not None:
+            clauses.append("trace_id = ?")
+            params.append(trace_id)
+        if since is not None:
+            clauses.append("timestamp >= ?")
+            params.append(since.isoformat())
+
+        where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
+        params.append(limit)
+
+        rows = self._conn.execute(
+            f"SELECT * FROM log_entries{where} ORDER BY timestamp DESC LIMIT ?",
+            params,
+        ).fetchall()
+
+        entries: list[LogEntry] = []
+        for r in rows:
+            d = dict(r)
+            d["level"] = LogLevel(d["level"])
+            d["metadata"] = json.loads(d["metadata"])
+            d["timestamp"] = datetime.fromisoformat(d["timestamp"])
+            entries.append(LogEntry(**d))
+        return entries
+
+    def get_level_counts(self) -> dict[str, int]:
+        """Return the count of entries at each log level.
+
+        Returns
+        -------
+        dict[str, int]
+            Level name → count.
+        """
+        rows = self._conn.execute(
+            "SELECT level, COUNT(*) as cnt FROM log_entries GROUP BY level"
+        ).fetchall()
+        return {r["level"]: r["cnt"] for r in rows}
+
+    def search(
+        self, query: str, limit: int = 50
+    ) -> list[LogEntry]:
+        """Full-text search across log messages.
+
+        Parameters
+        ----------
+        query : str
+            Search string.
+        limit : int
+            Maximum results.
+
+        Returns
+        -------
+        list[LogEntry]
+            Matching entries, most recent first.
+        """
+        pattern = f"%{query}%"
+        rows = self._conn.execute(
+            "SELECT * FROM log_entries WHERE message LIKE ? "
+            "ORDER BY timestamp DESC LIMIT ?",
+            (pattern, limit),
+        ).fetchall()
+
+        entries: list[LogEntry] = []
+        for r in rows:
+            d = dict(r)
+            d["level"] = LogLevel(d["level"])
+            d["metadata"] = json.loads(d["metadata"])
+            d["timestamp"] = datetime.fromisoformat(d["timestamp"])
+            entries.append(LogEntry(**d))
+        return entries
+
+    def count(
+        self,
+        level: LogLevel | None = None,
+        component: str | None = None,
+        skill_id: str | None = None,
+    ) -> int:
+        """Count log entries matching filters.
+
+        Parameters
+        ----------
+        level : LogLevel | None
+            Filter by level.
+        component : str | None
+            Filter by component.
+        skill_id : str | None
+            Filter by skill ID.
+
+        Returns
+        -------
+        int
+            Number of matching entries.
+        """
+        clauses: list[str] = []
+        params: list[Any] = []
+
+        if level is not None:
+            clauses.append("level = ?")
+            params.append(level.value)
+        if component is not None:
+            clauses.append("component = ?")
+            params.append(component)
+        if skill_id is not None:
+            clauses.append("skill_id = ?")
+            params.append(skill_id)
+
+        where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
+
+        row = self._conn.execute(
+            f"SELECT COUNT(*) as cnt FROM log_entries{where}",
+            params,
+        ).fetchone()
+        return row["cnt"] if row else 0
+
+    # ------------------------------------------------------------------
+    # Cleanup
+    # ------------------------------------------------------------------
+
+    def close(self) -> None:
+        """Close the database connection."""
+        self._conn.close()
diff --git a/skillforge/observability/metrics.py b/skillforge/observability/metrics.py
new file mode 100644
index 0000000..4e6e345
--- /dev/null
+++ b/skillforge/observability/metrics.py
@@ -0,0 +1,430 @@
+"""Metrics collector — aggregates and persists numerical skill metrics.
+
+Supports counters, gauges, and histograms (bucketed) with SQLite
+persistence and aggregation queries.
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+@dataclass
+class MetricPoint:
+    """A single metric data point.
+
+    Attributes:
+        name: Metric name (e.g. ``skill.latency_ms``).
+        value: Numerical value.
+        labels: Dimensional labels (e.g. ``{"skill_id": "…"}``).
+        timestamp: When the metric was recorded.
+    """
+
+    name: str
+    value: float
+    labels: dict[str, str] = field(default_factory=dict)
+    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+@dataclass
+class MetricSummary:
+    """Aggregated summary for a metric.
+
+    Attributes:
+        name: Metric name.
+        count: Number of data points.
+        sum: Sum of all values.
+        min: Minimum value.
+        max: Maximum value.
+        mean: Average value.
+        p50: Median (50th percentile) value.
+        p95: 95th percentile value.
+        p99: 99th percentile value.
+    """
+
+    name: str
+    count: int = 0
+    sum: float = 0.0
+    min: float = 0.0
+    max: float = 0.0
+    mean: float = 0.0
+    p50: float = 0.0
+    p95: float = 0.0
+    p99: float = 0.0
+
+
+class MetricsCollector:
+    """Persistent metrics collector backed by SQLite.
+
+    Provides a simple interface for recording and querying metrics
+    across skill executions.
+
+    Parameters
+    ----------
+    db_path : str | Path | None
+        Path to the SQLite database.
+        Defaults to ``~/.skillforge/metrics.db``.
+    """
+
+    # Default histogram bucket boundaries
+    DEFAULT_BUCKETS: list[float] = [
+        1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0,
+        1000.0, 2500.0, 5000.0, 10000.0, float("inf"),
+    ]
+
+    def __init__(self, db_path: str | Path | None = None) -> None:
+        self._db_path = str(db_path or Path.home() / ".skillforge" / "metrics.db")
+        Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
+        self._conn = sqlite3.connect(self._db_path)
+        self._conn.row_factory = sqlite3.Row
+        self._conn.execute("PRAGMA journal_mode=WAL")
+        self._ensure_tables()
+
+    # ------------------------------------------------------------------
+    # Schema
+    # ------------------------------------------------------------------
+
+    def _ensure_tables(self) -> None:
+        """Create metric tables if they do not exist."""
+        self._conn.executescript(
+            """
+            CREATE TABLE IF NOT EXISTS metric_points (
+                id          TEXT PRIMARY KEY,
+                name        TEXT NOT NULL,
+                value       REAL NOT NULL,
+                labels      TEXT NOT NULL DEFAULT '{}',
+                recorded_at TEXT NOT NULL
+            );
+            CREATE INDEX IF NOT EXISTS idx_mp_name
+                ON metric_points(name);
+            CREATE INDEX IF NOT EXISTS idx_mp_recorded
+                ON metric_points(recorded_at);
+            """
+        )
+        self._conn.commit()
+
+    # ------------------------------------------------------------------
+    # Recording
+    # ------------------------------------------------------------------
+
+    def record(
+        self,
+        name: str,
+        value: float,
+        labels: dict[str, str] | None = None,
+        timestamp: datetime | None = None,
+    ) -> MetricPoint:
+        """Record a single metric data point.
+
+        Parameters
+        ----------
+        name : str
+            Metric name (e.g. ``"skill.latency_ms"``).
+        value : float
+            Numerical value.
+        labels : dict[str, str] | None
+            Dimensional labels.
+        timestamp : datetime | None
+            When the metric occurred.  Defaults to now.
+
+        Returns
+        -------
+        MetricPoint
+            The recorded data point.
+        """
+        ts = timestamp or datetime.now(timezone.utc)
+        point = MetricPoint(name=name, value=value, labels=labels or {}, timestamp=ts)
+        self._conn.execute(
+            "INSERT INTO metric_points (id, name, value, labels, recorded_at) "
+            "VALUES (?, ?, ?, ?, ?)",
+            (
+                str(uuid.uuid4()),
+                point.name,
+                point.value,
+                json.dumps(point.labels),
+                point.timestamp.isoformat(),
+            ),
+        )
+        self._conn.commit()
+        return point
+
+    def record_counter(
+        self, name: str, increment: float = 1.0, labels: dict[str, str] | None = None
+    ) -> MetricPoint:
+        """Record a counter increment.
+
+        Parameters
+        ----------
+        name : str
+            Counter name.
+        increment : float
+            Amount to increment.
+        labels : dict[str, str] | None
+            Dimensional labels.
+
+        Returns
+        -------
+        MetricPoint
+            The recorded data point.
+        """
+        return self.record(name, increment, labels)
+
+    def record_gauge(
+        self, name: str, value: float, labels: dict[str, str] | None = None
+    ) -> MetricPoint:
+        """Record a gauge value (point-in-time snapshot).
+
+        Parameters
+        ----------
+        name : str
+            Gauge name.
+        value : float
+            Current value.
+        labels : dict[str, str] | None
+            Dimensional labels.
+
+        Returns
+        -------
+        MetricPoint
+            The recorded data point.
+        """
+        return self.record(name, value, labels)
+
+    def record_timing(
+        self, name: str, duration_ms: float, labels: dict[str, str] | None = None
+    ) -> MetricPoint:
+        """Record a timing measurement.
+
+        Parameters
+        ----------
+        name : str
+            Timing metric name (e.g. ``"skill.load.duration_ms"``).
+        duration_ms : float
+            Duration in milliseconds.
+        labels : dict[str, str] | None
+            Dimensional labels.
+
+        Returns
+        -------
+        MetricPoint
+            The recorded data point.
+        """
+        return self.record(name, duration_ms, labels)
+
+    # ------------------------------------------------------------------
+    # Queries
+    # ------------------------------------------------------------------
+
+    def get_metric_points(
+        self,
+        name: str,
+        labels: dict[str, str] | None = None,
+        since: datetime | None = None,
+        limit: int = 1000,
+    ) -> list[MetricPoint]:
+        """Retrieve metric points for a given name.
+
+        Parameters
+        ----------
+        name : str
+            Metric name.
+        labels : dict[str, str] | None
+            Filter to points whose labels contain all specified pairs.
+        since : datetime | None
+            Only return points after this time.
+        limit : int
+            Maximum points to return.
+
+        Returns
+        -------
+        list[MetricPoint]
+            Matching data points, ordered by time.
+        """
+        clauses = ["name = ?"]
+        params: list[Any] = [name]
+
+        if since is not None:
+            clauses.append("recorded_at >= ?")
+            params.append(since.isoformat())
+
+        where = " AND ".join(clauses)
+        params.append(limit)
+
+        rows = self._conn.execute(
+            f"SELECT * FROM metric_points WHERE {where} "
+            "ORDER BY recorded_at LIMIT ?",
+            params,
+        ).fetchall()
+
+        points: list[MetricPoint] = []
+        for r in rows:
+            d = dict(r)
+            decoded_labels = json.loads(d["labels"])
+            # Filter by labels if requested
+            if labels:
+                if not all(decoded_labels.get(k) == v for k, v in labels.items()):
+                    continue
+            points.append(MetricPoint(
+                name=d["name"],
+                value=d["value"],
+                labels=decoded_labels,
+                timestamp=datetime.fromisoformat(d["recorded_at"]),
+            ))
+        return points
+
+    def summarize(
+        self,
+        name: str,
+        labels: dict[str, str] | None = None,
+        since: datetime | None = None,
+    ) -> MetricSummary:
+        """Compute an aggregate summary for a metric.
+
+        Parameters
+        ----------
+        name : str
+            Metric name.
+        labels : dict[str, str] | None
+            Filter to matching labels.
+        since : datetime | None
+            Only consider points after this time.
+
+        Returns
+        -------
+        MetricSummary
+            Aggregated statistics.
+        """
+        points = self.get_metric_points(
+            name, labels=labels, since=since, limit=100_000
+        )
+        if not points:
+            return MetricSummary(name=name)
+
+        values = sorted(p.value for p in points)
+        n = len(values)
+        total = sum(values)
+
+        def _percentile(sorted_vals: list[float], pct: float) -> float:
+            """Calculate percentile from a sorted list."""
+            if not sorted_vals:
+                return 0.0
+            k = (len(sorted_vals) - 1) * pct
+            f = int(k)
+            c = min(f + 1, len(sorted_vals) - 1)
+            d = k - f
+            return sorted_vals[f] + d * (sorted_vals[c] - sorted_vals[f])
+
+        return MetricSummary(
+            name=name,
+            count=n,
+            sum=total,
+            min=values[0],
+            max=values[-1],
+            mean=total / n,
+            p50=_percentile(values, 0.50),
+            p95=_percentile(values, 0.95),
+            p99=_percentile(values, 0.99),
+        )
+
+    def get_histogram(
+        self,
+        name: str,
+        buckets: list[float] | None = None,
+        labels: dict[str, str] | None = None,
+        since: datetime | None = None,
+    ) -> dict[str, int]:
+        """Compute a histogram distribution for a metric.
+
+        Parameters
+        ----------
+        name : str
+            Metric name.
+        buckets : list[float] | None
+            Bucket boundaries.  Defaults to :attr:`DEFAULT_BUCKETS`.
+        labels : dict[str, str] | None
+            Filter by labels.
+        since : datetime | None
+            Only consider points after this time.
+
+        Returns
+        -------
+        dict[str, int]
+            Bucket label → count mapping.
+        """
+        points = self.get_metric_points(
+            name, labels=labels, since=since, limit=100_000
+        )
+        if not points:
+            return {}
+
+        bucket_edges = buckets or self.DEFAULT_BUCKETS
+        counts: dict[str, int] = {}
+        for edge in bucket_edges:
+            label = f"<={edge}" if edge != float("inf") else "+Inf"
+            counts[label] = 0
+
+        for p in points:
+            for edge in bucket_edges:
+                label = f"<={edge}" if edge != float("inf") else "+Inf"
+                if p.value <= edge:
+                    counts[label] += 1
+                    break
+
+        return counts
+
+    def get_metric_names(self) -> list[str]:
+        """List all unique metric names.
+
+        Returns
+        -------
+        list[str]
+            All metric names, sorted alphabetically.
+        """
+        rows = self._conn.execute(
+            "SELECT DISTINCT name FROM metric_points ORDER BY name"
+        ).fetchall()
+        return [r["name"] for r in rows]
+
+    def count(
+        self,
+        name: str | None = None,
+        labels: dict[str, str] | None = None,
+    ) -> int:
+        """Count data points for a metric.
+
+        Parameters
+        ----------
+        name : str | None
+            Metric name.  If *None*, counts all data points.
+        labels : dict[str, str] | None
+            Filter by labels.
+
+        Returns
+        -------
+        int
+            Number of matching data points.
+        """
+        if name is not None:
+            row = self._conn.execute(
+                "SELECT COUNT(*) as cnt FROM metric_points WHERE name = ?",
+                (name,),
+            ).fetchone()
+        else:
+            row = self._conn.execute(
+                "SELECT COUNT(*) as cnt FROM metric_points"
+            ).fetchone()
+        return row["cnt"] if row else 0
+
+    # ------------------------------------------------------------------
+    # Cleanup
+    # ------------------------------------------------------------------
+
+    def close(self) -> None:
+        """Close the database connection."""
+        self._conn.close()
diff --git a/skillforge/observability/tracer.py b/skillforge/observability/tracer.py
new file mode 100644
index 0000000..32fc8ce
--- /dev/null
+++ b/skillforge/observability/tracer.py
@@ -0,0 +1,506 @@
+"""Distributed tracing for skill executions.
+
+Provides OpenTelemetry-inspired span-based tracing that tracks skill
+invocations, their latencies, and parent-child relationships — all
+persisted in SQLite for post-mortem analysis.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+import uuid
+from contextlib import contextmanager
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from enum import Enum
+from pathlib import Path
+from typing import Any, Generator
+
+
+class SpanStatus(Enum):
+    """Status of a tracing span."""
+
+    OK = "ok"
+    ERROR = "error"
+    UNSET = "unset"
+
+
+@dataclass
+class TraceContext:
+    """Lightweight context propagated across spans in a single trace.
+
+    Attributes:
+        trace_id: Globally unique trace identifier.
+        parent_span_id: The span that initiated the current span.
+    """
+
+    trace_id: str
+    parent_span_id: str | None = None
+
+
+@dataclass
+class Span:
+    """A single unit of work in a distributed trace.
+
+    Attributes:
+        span_id: Unique span identifier.
+        trace_id: Parent trace ID.
+        parent_span_id: Optional parent span ID.
+        operation: Human-readable operation name.
+        skill_id: The skill involved (may be empty for non-skill ops).
+        status: Span status.
+        start_time: When the span started.
+        end_time: When the span finished.
+        duration_ms: Duration in milliseconds.
+        attributes: Arbitrary key-value metadata.
+        events: Timestamped events within the span.
+    """
+
+    span_id: str
+    trace_id: str
+    parent_span_id: str | None = None
+    operation: str = ""
+    skill_id: str = ""
+    status: SpanStatus = SpanStatus.UNSET
+    start_time: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+    end_time: datetime | None = None
+    duration_ms: float = 0.0
+    attributes: dict[str, Any] = field(default_factory=dict)
+    events: list[dict[str, Any]] = field(default_factory=list)
+
+    def finish(self, status: SpanStatus = SpanStatus.OK) -> None:
+        """Mark the span as finished.
+
+        Parameters
+        ----------
+        status : SpanStatus
+            Final status of the span.
+        """
+        self.end_time = datetime.now(timezone.utc)
+        self.duration_ms = (self.end_time - self.start_time).total_seconds() * 1000
+        self.status = status
+
+    def add_event(
+        self, name: str, attributes: dict[str, Any] | None = None
+    ) -> None:
+        """Record a timestamped event within this span.
+
+        Parameters
+        ----------
+        name : str
+            Event name.
+        attributes : dict[str, Any] | None
+            Event metadata.
+        """
+        self.events.append({
+            "name": name,
+            "timestamp": datetime.now(timezone.utc).isoformat(),
+            "attributes": attributes or {},
+        })
+
+    def set_attribute(self, key: str, value: Any) -> None:
+        """Set an attribute on the span.
+
+        Parameters
+        ----------
+        key : str
+            Attribute key.
+        value : Any
+            Attribute value.
+        """
+        self.attributes[key] = value
+
+
+class SkillTracer:
+    """Persistent distributed tracer for skill executions.
+
+    Stores spans in SQLite for analysis.  Provides a context-manager
+    API for clean span lifecycle management.
+
+    Parameters
+    ----------
+    db_path : str | Path | None
+        Path to the SQLite database.
+        Defaults to ``~/.skillforge/traces.db``.
+    """
+
+    def __init__(self, db_path: str | Path | None = None) -> None:
+        self._db_path = str(db_path or Path.home() / ".skillforge" / "traces.db")
+        Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
+        self._conn = sqlite3.connect(self._db_path)
+        self._conn.row_factory = sqlite3.Row
+        self._conn.execute("PRAGMA journal_mode=WAL")
+        self._ensure_tables()
+
+    # ------------------------------------------------------------------
+    # Schema
+    # ------------------------------------------------------------------
+
+    def _ensure_tables(self) -> None:
+        """Create trace tables if they do not exist."""
+        self._conn.executescript(
+            """
+            CREATE TABLE IF NOT EXISTS spans (
+                span_id         TEXT PRIMARY KEY,
+                trace_id        TEXT NOT NULL,
+                parent_span_id  TEXT,
+                operation       TEXT NOT NULL DEFAULT '',
+                skill_id        TEXT NOT NULL DEFAULT '',
+                status          TEXT NOT NULL DEFAULT 'unset',
+                start_time      TEXT NOT NULL,
+                end_time        TEXT,
+                duration_ms     REAL NOT NULL DEFAULT 0.0,
+                attributes      TEXT NOT NULL DEFAULT '{}',
+                events          TEXT NOT NULL DEFAULT '[]'
+            );
+            CREATE INDEX IF NOT EXISTS idx_spans_trace ON spans(trace_id);
+            CREATE INDEX IF NOT EXISTS idx_spans_skill ON spans(skill_id);
+            CREATE INDEX IF NOT EXISTS idx_spans_start ON spans(start_time);
+            """
+        )
+        self._conn.commit()
+
+    # ------------------------------------------------------------------
+    # Trace creation
+    # ------------------------------------------------------------------
+
+    def new_trace(self) -> TraceContext:
+        """Create a new trace context.
+
+        Returns
+        -------
+        TraceContext
+            Fresh trace context with a unique trace ID.
+        """
+        return TraceContext(trace_id=str(uuid.uuid4()))
+
+    # ------------------------------------------------------------------
+    # Span lifecycle
+    # ------------------------------------------------------------------
+
+    def start_span(
+        self,
+        operation: str,
+        trace_id: str | None = None,
+        parent_span_id: str | None = None,
+        skill_id: str = "",
+        attributes: dict[str, Any] | None = None,
+    ) -> Span:
+        """Start a new span.
+
+        Parameters
+        ----------
+        operation : str
+            Human-readable operation name.
+        trace_id : str | None
+            Trace to attach to.  A new trace is created when *None*.
+        parent_span_id : str | None
+            Parent span ID for nesting.
+        skill_id : str
+            Skill involved in this operation.
+        attributes : dict[str, Any] | None
+            Initial span attributes.
+
+        Returns
+        -------
+        Span
+            The newly created span.
+        """
+        tid = trace_id or str(uuid.uuid4())
+        span = Span(
+            span_id=str(uuid.uuid4()),
+            trace_id=tid,
+            parent_span_id=parent_span_id,
+            operation=operation,
+            skill_id=skill_id,
+            attributes=attributes or {},
+        )
+        return span
+
+    def end_span(self, span: Span, status: SpanStatus = SpanStatus.OK) -> None:
+        """Finish a span and persist it.
+
+        Parameters
+        ----------
+        span : Span
+            The span to finish.
+        status : SpanStatus
+            Final status.
+        """
+        span.finish(status)
+        self._persist_span(span)
+
+    def _persist_span(self, span: Span) -> None:
+        """Write a span to the database."""
+        import json
+
+        self._conn.execute(
+            "INSERT OR REPLACE INTO spans VALUES "
+            "(:span_id,:trace_id,:parent_span_id,:operation,:skill_id,:status,"
+            ":start_time,:end_time,:duration_ms,:attributes,:events)",
+            {
+                "span_id": span.span_id,
+                "trace_id": span.trace_id,
+                "parent_span_id": span.parent_span_id,
+                "operation": span.operation,
+                "skill_id": span.skill_id,
+                "status": span.status.value,
+                "start_time": span.start_time.isoformat(),
+                "end_time": span.end_time.isoformat() if span.end_time else None,
+                "duration_ms": span.duration_ms,
+                "attributes": json.dumps(span.attributes),
+                "events": json.dumps(span.events),
+            },
+        )
+        self._conn.commit()
+
+    # ------------------------------------------------------------------
+    # Context-manager API
+    # ------------------------------------------------------------------
+
+    @contextmanager
+    def trace_span(
+        self,
+        operation: str,
+        skill_id: str = "",
+        trace_id: str | None = None,
+        parent_span_id: str | None = None,
+        attributes: dict[str, Any] | None = None,
+    ) -> Generator[Span, None, None]:
+        """Context manager that starts and automatically finishes a span.
+
+        On normal exit the span status is ``OK``.
+        On exception the status is ``ERROR``.
+
+        Parameters
+        ----------
+        operation : str
+            Operation name.
+        skill_id : str
+            Skill ID.
+        trace_id : str | None
+            Trace to attach to.
+        parent_span_id : str | None
+            Parent span for nesting.
+        attributes : dict[str, Any] | None
+            Initial attributes.
+
+        Yields
+        ------
+        Span
+            The active span.
+        """
+        span = self.start_span(
+            operation=operation,
+            trace_id=trace_id,
+            parent_span_id=parent_span_id,
+            skill_id=skill_id,
+            attributes=attributes,
+        )
+        try:
+            yield span
+            self.end_span(span, SpanStatus.OK)
+        except Exception:
+            self.end_span(span, SpanStatus.ERROR)
+            raise
+
+    # ------------------------------------------------------------------
+    # Queries
+    # ------------------------------------------------------------------
+
+    def get_trace(self, trace_id: str) -> list[Span]:
+        """Retrieve all spans in a trace.
+
+        Parameters
+        ----------
+        trace_id : str
+            Trace ID to retrieve.
+
+        Returns
+        -------
+        list[Span]
+            All spans in the trace, ordered by start time.
+        """
+        import json as _json
+
+        rows = self._conn.execute(
+            "SELECT * FROM spans WHERE trace_id = ? ORDER BY start_time",
+            (trace_id,),
+        ).fetchall()
+
+        spans: list[Span] = []
+        for r in rows:
+            d = dict(r)
+            d["status"] = SpanStatus(d["status"])
+            d["attributes"] = _json.loads(d["attributes"])
+            d["events"] = _json.loads(d["events"])
+            d["start_time"] = datetime.fromisoformat(d["start_time"])
+            if d["end_time"]:
+                d["end_time"] = datetime.fromisoformat(d["end_time"])
+            spans.append(Span(**d))
+        return spans
+
+    def get_skill_spans(
+        self, skill_id: str, limit: int = 100
+    ) -> list[Span]:
+        """Retrieve recent spans for a specific skill.
+
+        Parameters
+        ----------
+        skill_id : str
+            Skill to query.
+        limit : int
+            Maximum spans to return.
+
+        Returns
+        -------
+        list[Span]
+            Recent spans for the skill, most recent first.
+        """
+        import json as _json
+
+        rows = self._conn.execute(
+            "SELECT * FROM spans WHERE skill_id = ? "
+            "ORDER BY start_time DESC LIMIT ?",
+            (skill_id, limit),
+        ).fetchall()
+
+        spans: list[Span] = []
+        for r in rows:
+            d = dict(r)
+            d["status"] = SpanStatus(d["status"])
+            d["attributes"] = _json.loads(d["attributes"])
+            d["events"] = _json.loads(d["events"])
+            d["start_time"] = datetime.fromisoformat(d["start_time"])
+            if d["end_time"]:
+                d["end_time"] = datetime.fromisoformat(d["end_time"])
+            spans.append(Span(**d))
+        return spans
+
+    def get_slow_spans(
+        self, threshold_ms: float = 1000.0, limit: int = 50
+    ) -> list[Span]:
+        """Retrieve spans that exceeded a duration threshold.
+
+        Parameters
+        ----------
+        threshold_ms : float
+            Minimum duration in milliseconds.
+        limit : int
+            Maximum spans to return.
+
+        Returns
+        -------
+        list[Span]
+            Slow spans, sorted by duration descending.
+        """
+        import json as _json
+
+        rows = self._conn.execute(
+            "SELECT * FROM spans WHERE duration_ms >= ? "
+            "ORDER BY duration_ms DESC LIMIT ?",
+            (threshold_ms, limit),
+        ).fetchall()
+
+        spans: list[Span] = []
+        for r in rows:
+            d = dict(r)
+            d["status"] = SpanStatus(d["status"])
+            d["attributes"] = _json.loads(d["attributes"])
+            d["events"] = _json.loads(d["events"])
+            d["start_time"] = datetime.fromisoformat(d["start_time"])
+            if d["end_time"]:
+                d["end_time"] = datetime.fromisoformat(d["end_time"])
+            spans.append(Span(**d))
+        return spans
+
+    def get_error_spans(self, limit: int = 50) -> list[Span]:
+        """Retrieve spans that ended with an error.
+
+        Parameters
+        ----------
+        limit : int
+            Maximum spans to return.
+
+        Returns
+        -------
+        list[Span]
+            Error spans, most recent first.
+        """
+        import json as _json
+
+        rows = self._conn.execute(
+            "SELECT * FROM spans WHERE status = 'error' "
+            "ORDER BY start_time DESC LIMIT ?",
+            (limit,),
+        ).fetchall()
+
+        spans: list[Span] = []
+        for r in rows:
+            d = dict(r)
+            d["status"] = SpanStatus(d["status"])
+            d["attributes"] = _json.loads(d["attributes"])
+            d["events"] = _json.loads(d["events"])
+            d["start_time"] = datetime.fromisoformat(d["start_time"])
+            if d["end_time"]:
+                d["end_time"] = datetime.fromisoformat(d["end_time"])
+            spans.append(Span(**d))
+        return spans
+
+    def get_trace_stats(self) -> dict[str, Any]:
+        """Return aggregate trace statistics.
+
+        Returns
+        -------
+        dict[str, Any]
+            Statistics including total traces, total spans, average
+            duration, error rate, and slowest operations.
+        """
+        total_spans = self._conn.execute(
+            "SELECT COUNT(*) as cnt FROM spans"
+        ).fetchone()["cnt"]
+
+        unique_traces = self._conn.execute(
+            "SELECT COUNT(DISTINCT trace_id) as cnt FROM spans"
+        ).fetchone()["cnt"]
+
+        error_count = self._conn.execute(
+            "SELECT COUNT(*) as cnt FROM spans WHERE status = 'error'"
+        ).fetchone()["cnt"]
+
+        avg_duration = self._conn.execute(
+            "SELECT AVG(duration_ms) as avg_ms FROM spans"
+        ).fetchone()["avg_ms"] or 0.0
+
+        p99_row = self._conn.execute(
+            "SELECT duration_ms FROM spans ORDER BY duration_ms DESC LIMIT 1 "
+            "OFFSET (SELECT MAX(0, COUNT(*) / 100 - 1) FROM spans)"
+        ).fetchone()
+        p99_ms = p99_row["duration_ms"] if p99_row else 0.0
+
+        # Top 5 slowest operations
+        top_ops = self._conn.execute(
+            "SELECT operation, AVG(duration_ms) as avg_ms, COUNT(*) as cnt "
+            "FROM spans GROUP BY operation ORDER BY avg_ms DESC LIMIT 5"
+        ).fetchall()
+
+        return {
+            "total_spans": total_spans,
+            "unique_traces": unique_traces,
+            "error_count": error_count,
+            "error_rate": error_count / total_spans if total_spans else 0.0,
+            "avg_duration_ms": round(avg_duration, 2),
+            "p99_duration_ms": round(p99_ms, 2),
+            "top_operations": [
+                {"operation": r["operation"], "avg_ms": round(r["avg_ms"], 2), "count": r["cnt"]}
+                for r in top_ops
+            ],
+        }
+
+    # ------------------------------------------------------------------
+    # Cleanup
+    # ------------------------------------------------------------------
+
+    def close(self) -> None:
+        """Close the database connection."""
+        self._conn.close()
diff --git a/skillforge/versioning/__init__.py b/skillforge/versioning/__init__.py
new file mode 100644
index 0000000..4c6cb42
--- /dev/null
+++ b/skillforge/versioning/__init__.py
@@ -0,0 +1,17 @@
+"""SkillForge Versioning — semantic versioning, history, rollback, and diff."""
+
+from skillforge.versioning.version_manager import (
+    VersionManager,
+    SemanticVersion,
+    VersionRecord,
+    VersionDiff,
+    ChangeType,
+)
+
+__all__ = [
+    "VersionManager",
+    "SemanticVersion",
+    "VersionRecord",
+    "VersionDiff",
+    "ChangeType",
+]
diff --git a/skillforge/versioning/version_manager.py b/skillforge/versioning/version_manager.py
new file mode 100644
index 0000000..5d89723
--- /dev/null
+++ b/skillforge/versioning/version_manager.py
@@ -0,0 +1,710 @@
+"""Version manager — semantic versioning, history tracking, rollback, and diff.
+
+Provides comprehensive version management for skills including:
+- Semantic version parsing, comparison, and bumping
+- Full version history with snapshots and change logs
+- Rollback to any previous version
+- Content diff between versions
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sqlite3
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+
+# Semantic version pattern
+_SEMVER_RE = re.compile(
+    r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)"
+    r"(?:-(?P
[0-9A-Za-z\-.]+))?(?:\+(?P[0-9A-Za-z\-.]+))?$"
+)
+
+
+class ChangeType(Enum):
+    """Type of change that triggered a version bump."""
+
+    MAJOR = "major"  # Breaking changes
+    MINOR = "minor"  # New features
+    PATCH = "patch"  # Bug fixes
+    PRERELEASE = "pre"
+    BUILD = "build"
+
+
+@dataclass
+class SemanticVersion:
+    """A parsed semantic version.
+
+    Supports parsing, comparison, and bumping per semver 2.0.0 spec.
+
+    Attributes:
+        major: Major version number.
+        minor: Minor version number.
+        patch: Patch version number.
+        pre: Pre-release identifier (e.g. ``"alpha.1"``).
+        build: Build metadata (e.g. ``"abc123"``).
+    """
+
+    major: int = 0
+    minor: int = 0
+    patch: int = 0
+    pre: str = ""
+    build: str = ""
+
+    # ------------------------------------------------------------------
+    # Parsing
+    # ------------------------------------------------------------------
+
+    @classmethod
+    def parse(cls, version_string: str) -> "SemanticVersion":
+        """Parse a semantic version string.
+
+        Parameters
+        ----------
+        version_string : str
+            Version string (e.g. ``"1.2.3-beta.1+build.42"``).
+
+        Returns
+        -------
+        SemanticVersion
+            Parsed version object.
+
+        Raises
+        ------
+        ValueError
+            If the string is not a valid semantic version.
+        """
+        m = _SEMVER_RE.match(version_string.strip())
+        if m is None:
+            raise ValueError(
+                f"Invalid semantic version: '{version_string}'. "
+                "Expected format: MAJOR.MINOR.PATCH[-pre][+build]"
+            )
+        return cls(
+            major=int(m.group("major")),
+            minor=int(m.group("minor")),
+            patch=int(m.group("patch")),
+            pre=m.group("pre") or "",
+            build=m.group("build") or "",
+        )
+
+    # ------------------------------------------------------------------
+    # Formatting
+    # ------------------------------------------------------------------
+
+    def __str__(self) -> str:
+        """Return the canonical version string.
+
+        Returns
+        -------
+        str
+            Version string in ``MAJOR.MINOR.PATCH[-pre][+build]`` format.
+        """
+        v = f"{self.major}.{self.minor}.{self.patch}"
+        if self.pre:
+            v += f"-{self.pre}"
+        if self.build:
+            v += f"+{self.build}"
+        return v
+
+    def __repr__(self) -> str:
+        return f"SemanticVersion('{self}')"
+
+    # ------------------------------------------------------------------
+    # Comparison
+    # ------------------------------------------------------------------
+
+    def _cmp_key(self) -> tuple[int, int, int, list[str]]:
+        """Build a comparison key (pre-release excluded from numeric comparison)."""
+        pre_parts = (
+            [int(p) if p.isdigit() else p for p in self.pre.split(".")]
+            if self.pre
+            else []
+        )
+        # A pre-release version has lower precedence than the normal version
+        # We signal this by adding a sentinel: no-pre < empty-string < actual-pre
+        pre_key: list[str] = []
+        if self.pre:
+            pre_key = [str(p) for p in pre_parts]
+        return (self.major, self.minor, self.patch, pre_key)
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, SemanticVersion):
+            return NotImplemented
+        return self._cmp_key() == other._cmp_key()
+
+    def __lt__(self, other: "SemanticVersion") -> bool:
+        return self._cmp_key() < other._cmp_key()
+
+    def __le__(self, other: "SemanticVersion") -> bool:
+        return self._cmp_key() <= other._cmp_key()
+
+    def __gt__(self, other: "SemanticVersion") -> bool:
+        return self._cmp_key() > other._cmp_key()
+
+    def __ge__(self, other: "SemanticVersion") -> bool:
+        return self._cmp_key() >= other._cmp_key()
+
+    def __hash__(self) -> int:
+        return hash(self._cmp_key())
+
+    # ------------------------------------------------------------------
+    # Bumping
+    # ------------------------------------------------------------------
+
+    def bump(self, change_type: ChangeType) -> "SemanticVersion":
+        """Create a new version by bumping the specified component.
+
+        Parameters
+        ----------
+        change_type : ChangeType
+            Which component to bump.
+
+        Returns
+        -------
+        SemanticVersion
+            The bumped version.
+        """
+        if change_type == ChangeType.MAJOR:
+            return SemanticVersion(major=self.major + 1, minor=0, patch=0)
+        elif change_type == ChangeType.MINOR:
+            return SemanticVersion(major=self.major, minor=self.minor + 1, patch=0)
+        elif change_type == ChangeType.PATCH:
+            return SemanticVersion(major=self.major, minor=self.minor, patch=self.patch + 1)
+        else:
+            return SemanticVersion(
+                major=self.major, minor=self.minor, patch=self.patch
+            )
+
+
+@dataclass
+class VersionRecord:
+    """A version history record.
+
+    Attributes:
+        id: Unique record identifier.
+        skill_id: The skill this version belongs to.
+        version: Semantic version string.
+        change_type: What kind of change triggered this version.
+        changelog: Human-readable description of changes.
+        snapshot: Full skill state at this version (JSON-serializable).
+        author: Who made the change.
+        created_at: When this version was created.
+    """
+
+    id: str
+    skill_id: str
+    version: str
+    change_type: ChangeType = ChangeType.PATCH
+    changelog: str = ""
+    snapshot: dict[str, Any] = field(default_factory=dict)
+    author: str = ""
+    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+@dataclass
+class VersionDiff:
+    """Diff between two versions.
+
+    Attributes:
+        from_version: Source version.
+        to_version: Target version.
+        added: Keys added in the new version.
+        removed: Keys removed in the new version.
+        modified: Keys that changed value.
+        summary: Human-readable diff summary.
+    """
+
+    from_version: str = ""
+    to_version: str = ""
+    added: dict[str, Any] = field(default_factory=dict)
+    removed: dict[str, Any] = field(default_factory=dict)
+    modified: dict[str, dict[str, Any]] = field(default_factory=dict)
+    summary: str = ""
+
+    @property
+    def has_changes(self) -> bool:
+        """Check if there are any changes.
+
+        Returns
+        -------
+        bool
+            True if there are additions, removals, or modifications.
+        """
+        return bool(self.added or self.removed or self.modified)
+
+
+class VersionManager:
+    """Manages semantic versioning and version history for skills.
+
+    Stores version history in SQLite, supports rollback to any previous
+            Path to the SQLite database.
+        Defaults to ``~/.skillforge/versions.db``.
+    """
+
+    def __init__(self, db_path: str | Path | None = None) -> None:
+        self._db_path = str(db_path or Path.home() / ".skillforge" / "versions.db")
+        Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
+        self._conn = sqlite3.connect(self._db_path)
+        self._conn.row_factory = sqlite3.Row
+        self._conn.execute("PRAGMA journal_mode=WAL")
+        self._ensure_tables()
+
+    # ------------------------------------------------------------------
+    # Schema
+    # ------------------------------------------------------------------
+
+    def _ensure_tables(self) -> None:
+        """Create version tables if they do not exist."""
+        self._conn.executescript(
+            """
+            CREATE TABLE IF NOT EXISTS version_history (
+                id          TEXT PRIMARY KEY,
+                skill_id    TEXT NOT NULL,
+                version     TEXT NOT NULL,
+                change_type TEXT NOT NULL DEFAULT 'patch',
+                changelog   TEXT NOT NULL DEFAULT '',
+                snapshot    TEXT NOT NULL DEFAULT '{}',
+                author      TEXT NOT NULL DEFAULT '',
+                created_at  TEXT NOT NULL
+            );
+            CREATE INDEX IF NOT EXISTS idx_vh_skill
+                ON version_history(skill_id);
+            CREATE INDEX IF NOT EXISTS idx_vh_version
+                ON version_history(version);
+            """
+        )
+        self._conn.commit()
+
+    # ------------------------------------------------------------------
+    # Version recording
+    # ------------------------------------------------------------------
+
+    def record_version(
+        self,
+        skill_id: str,
+        version: str,
+        change_type: ChangeType = ChangeType.PATCH,
+        changelog: str = "",
+        snapshot: dict[str, Any] | None = None,
+        author: str = "",
+    ) -> VersionRecord:
+        """Record a new version in the history.
+
+        Parameters
+        ----------
+        skill_id : str
+            The skill being versioned.
+        version : str
+            Semantic version string.
+        change_type : ChangeType
+            Type of change.
+        changelog : str
+            Description of changes.
+        snapshot : dict[str, Any] | None
+            Full skill state at this version.
+        author : str
+            Who made the change.
+
+        Returns
+        -------
+        VersionRecord
+            The created version record.
+
+        Raises
+        ------
+        ValueError
+            If the version string is invalid.
+        """
+        # Validate version string
+        sv = SemanticVersion.parse(version)
+
+        record_id = str(uuid.uuid4())
+        now = datetime.now(timezone.utc)
+
+        record = VersionRecord(
+            id=record_id,
+            skill_id=skill_id,
+            version=str(sv),
+            change_type=change_type,
+            changelog=changelog,
+            snapshot=snapshot or {},
+            author=author,
+            created_at=now,
+        )
+
+        self._conn.execute(
+            "INSERT INTO version_history VALUES "
+            "(:id,:skill_id,:version,:change_type,:changelog,:snapshot,"
+            ":author,:created_at)",
+            {
+                "id": record.id,
+                "skill_id": record.skill_id,
+                "version": record.version,
+                "change_type": record.change_type.value,
+                "changelog": record.changelog,
+                "snapshot": json.dumps(record.snapshot),
+                "author": record.author,
+                "created_at": record.created_at.isoformat(),
+            },
+        )
+        self._conn.commit()
+        return record
+
+    # ------------------------------------------------------------------
+    # Bumping
+    # ------------------------------------------------------------------
+
+    def bump_version(
+        self,
+        skill_id: str,
+        change_type: ChangeType,
+        changelog: str = "",
+        snapshot: dict[str, Any] | None = None,
+        author: str = "",
+    ) -> VersionRecord:
+        """Auto-bump the version for a skill based on change type.
+
+        Looks up the latest version, increments the appropriate component,
+        and records the new version.
+
+        Parameters
+        ----------
+        skill_id : str
+            Skill to bump.
+        change_type : ChangeType
+            Type of change (MAJOR, MINOR, PATCH).
+        changelog : str
+            Description of changes.
+        snapshot : dict[str, Any] | None
+            Full skill state.
+        author : str
+            Who made the change.
+
+        Returns
+        -------
+        VersionRecord
+            New version record.
+        """
+        latest = self.get_latest_version(skill_id)
+        if latest is None:
+            current = SemanticVersion(0, 0, 0)
+        else:
+            current = SemanticVersion.parse(latest.version)
+
+        new_version = current.bump(change_type)
+
+        return self.record_version(
+            skill_id=skill_id,
+            version=str(new_version),
+            change_type=change_type,
+            changelog=changelog,
+            snapshot=snapshot,
+            author=author,
+        )
+
+    # ------------------------------------------------------------------
+    # Queries
+    # ------------------------------------------------------------------
+
+    def get_latest_version(self, skill_id: str) -> VersionRecord | None:
+        """Get the latest version record for a skill.
+
+        Parameters
+        ----------
+        skill_id : str
+            Skill to query.
+
+        Returns
+        -------
+        VersionRecord | None
+            The latest version record, or *None* if no versions exist.
+        """
+        row = self._conn.execute(
+            "SELECT * FROM version_history WHERE skill_id = ? "
+            "ORDER BY created_at DESC LIMIT 1",
+            (skill_id,),
+        ).fetchone()
+        if row is None:
+            return None
+        return self._row_to_record(row)
+
+    def get_version(
+        self, skill_id: str, version: str
+    ) -> VersionRecord | None:
+        """Get a specific version record.
+
+        Parameters
+        ----------
+        skill_id : str
+            Skill to query.
+        version : str
+            Version string to look up.
+
+        Returns
+        -------
+        VersionRecord | None
+            The version record, or *None* if not found.
+        """
+        row = self._conn.execute(
+            "SELECT * FROM version_history WHERE skill_id = ? AND version = ?",
+            (skill_id, version),
+        ).fetchone()
+        if row is None:
+            return None
+        return self._row_to_record(row)
+
+    def get_history(
+        self, skill_id: str, limit: int = 100
+    ) -> list[VersionRecord]:
+        """Get the full version history for a skill.
+
+        Parameters
+        ----------
+        skill_id : str
+            Skill to query.
+        limit : int
+            Maximum records to return.
+
+        Returns
+        -------
+        list[VersionRecord]
+            Version records, most recent first.
+        """
+        rows = self._conn.execute(
+            "SELECT * FROM version_history WHERE skill_id = ? "
+            "ORDER BY created_at DESC LIMIT ?",
+            (skill_id, limit),
+        ).fetchall()
+        return [self._row_to_record(r) for r in rows]
+
+    # ------------------------------------------------------------------
+    # Rollback
+    # ------------------------------------------------------------------
+
+    def rollback(
+        self, skill_id: str, target_version: str
+    ) -> VersionRecord | None:
+        """Rollback a skill to a previous version.
+
+        Creates a *new* version record with the snapshot from the
+        target version (doesn't delete history).
+
+        Parameters
+        ----------
+        skill_id : str
+            Skill to rollback.
+        target_version : str
+            Version to restore.
+
+        Returns
+        -------
+        VersionRecord | None
+            The new rollback version record, or *None* if the target
+            version was not found.
+        """
+        target = self.get_version(skill_id, target_version)
+        if target is None:
+            return None
+
+        # Create a new version record with the old snapshot
+        return self.record_version(
+            skill_id=skill_id,
+            version=target.version,
+            change_type=ChangeType.PATCH,
+            changelog=f"Rollback to version {target_version}",
+            snapshot=target.snapshot,
+            author="system:rollback",
+        )
+
+    # ------------------------------------------------------------------
+    # Diff
+    # ------------------------------------------------------------------
+
+    def diff(
+        self, skill_id: str, from_version: str, to_version: str
+    ) -> VersionDiff:
+        """Compute a diff between two versions.
+
+        Compares the snapshot dictionaries and reports additions,
+        removals, and modifications.
+
+        Parameters
+        ----------
+        skill_id : str
+            Skill to diff.
+        from_version : str
+            Source version.
+        to_version : str
+            Target version.
+
+        Returns
+        -------
+        VersionDiff
+            Detailed diff report.
+
+        Raises
+        ------
+        ValueError
+            If either version is not found.
+        """
+        from_rec = self.get_version(skill_id, from_version)
+        to_rec = self.get_version(skill_id, to_version)
+
+        if from_rec is None:
+            raise ValueError(f"Version '{from_version}' not found for skill '{skill_id}'")
+        if to_rec is None:
+            raise ValueError(f"Version '{to_version}' not found for skill '{skill_id}'")
+
+        from_snap = from_rec.snapshot
+        to_snap = to_rec.snapshot
+
+        added: dict[str, Any] = {}
+        removed: dict[str, Any] = {}
+        modified: dict[str, dict[str, Any]] = {}
+
+        all_keys = set(from_snap.keys()) | set(to_snap.keys())
+        for key in sorted(all_keys):
+            if key not in from_snap:
+                added[key] = to_snap[key]
+            elif key not in to_snap:
+                removed[key] = from_snap[key]
+            elif from_snap[key] != to_snap[key]:
+                modified[key] = {"from": from_snap[key], "to": to_snap[key]}
+
+        n_added = len(added)
+        n_removed = len(removed)
+        n_modified = len(modified)
+
+        parts: list[str] = []
+        if n_added:
+            parts.append(f"{n_added} additions")
+        if n_removed:
+            parts.append(f"{n_removed} removals")
+        if n_modified:
+            parts.append(f"{n_modified} modifications")
+        if not parts:
+            parts.append("no changes")
+
+        summary = (
+            f"Diff from {from_version} to {to_version}: "
+            + ", ".join(parts)
+        )
+
+        return VersionDiff(
+            from_version=from_version,
+            to_version=to_version,
+            added=added,
+            removed=removed,
+            modified=modified,
+            summary=summary,
+        )
+
+    def diff_latest(
+        self, skill_id: str, against_version: str
+    ) -> VersionDiff:
+        """Diff the latest version against a specified version.
+
+        Parameters
+        ----------
+        skill_id : str
+            Skill to diff.
+        against_version : str
+            Version to compare the latest against.
+
+        Returns
+        -------
+        VersionDiff
+            Diff report.
+
+        Raises
+        ------
+        ValueError
+            If no versions exist or the specified version is not found.
+        """
+        latest = self.get_latest_version(skill_id)
+        if latest is None:
+            raise ValueError(f"No versions found for skill '{skill_id}'")
+
+        return self.diff(skill_id, against_version, latest.version)
+
+    # ------------------------------------------------------------------
+    # Changelog
+    # ------------------------------------------------------------------
+
+    def get_changelog(
+        self, skill_id: str, limit: int = 20
+    ) -> list[dict[str, Any]]:
+        """Generate a formatted changelog for a skill.
+
+        Parameters
+        ----------
+        skill_id : str
+            Skill to query.
+        limit : int
+            Maximum entries.
+
+        Returns
+        -------
+        list[dict[str, Any]]
+            Changelog entries with version, change_type, changelog, and
+            timestamp.
+        """
+        records = self.get_history(skill_id, limit=limit)
+        return [
+            {
+                "version": r.version,
+                "change_type": r.change_type.value,
+                "changelog": r.changelog,
+                "author": r.author,
+                "created_at": r.created_at.isoformat(),
+            }
+            for r in records
+        ]
+
+    # ------------------------------------------------------------------
+    # Helpers
+    # ------------------------------------------------------------------
+
+    @staticmethod
+    def _row_to_record(row: sqlite3.Row) -> VersionRecord:
+        """Convert a database row to a VersionRecord."""
+        d = dict(row)
+        d["change_type"] = ChangeType(d["change_type"])
+        d["snapshot"] = json.loads(d["snapshot"])
+        d["created_at"] = datetime.fromisoformat(d["created_at"])
+        return VersionRecord(**d)
+
+    def count(self, skill_id: str | None = None) -> int:
+        """Count version records.
+
+        Parameters
+        ----------
+        skill_id : str | None
+            If given, count only versions for this skill.
+
+        Returns
+        -------
+        int
+            Number of version records.
+        """
+        if skill_id is not None:
+            row = self._conn.execute(
+                "SELECT COUNT(*) as cnt FROM version_history WHERE skill_id = ?",
+                (skill_id,),
+            ).fetchone()
+        else:
+            row = self._conn.execute(
+                "SELECT COUNT(*) as cnt FROM version_history"
+            ).fetchone()
+        return row["cnt"] if row else 0
+
+    def close(self) -> None:
+        """Close the database connection."""
+        self._conn.close()
diff --git a/tests/test_phase5a.py b/tests/test_phase5a.py
new file mode 100644
index 0000000..77c779b
--- /dev/null
+++ b/tests/test_phase5a.py
@@ -0,0 +1,1077 @@
+"""Tests for Phase 5a modules: ElasticMemory and AlertManager.
+
+Uses pytest with tmp_path for DB isolation.  Tests cover:
+- Memory CRUD, recall, consolidation, compaction
+- Alert rule management, firing, lifecycle, callbacks, summaries
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from skillforge.core.graph import SkillDependencyGraph
+from skillforge.core.registry import Skill, SkillLifecycle, SkillRegistry
+from skillforge.core.tracker import Outcome, QValueTracker
+from skillforge.intelligence.health_monitor import HealthMonitor
+from skillforge.advanced.elastic_memory import ElasticMemory, MemoryEntry
+from skillforge.intelligence.alert_manager import (
+    Alert,
+    AlertManager,
+    AlertRule,
+    AlertRuleType,
+    AlertSeverity,
+    AlertStatus,
+)
+
+
+# -----------------------------------------------------------------------
+# Shared fixtures
+# -----------------------------------------------------------------------
+
+
+@pytest.fixture
+def db_dir(tmp_path: Path) -> Path:
+    """Create a temp directory for DB files."""
+    d = tmp_path / "db"
+    d.mkdir()
+    return d
+
+
+@pytest.fixture
+def registry(db_dir: Path) -> SkillRegistry:
+    """Fresh registry backed by a temp DB."""
+    reg = SkillRegistry(db_path=db_dir / "skills.db")
+    yield reg
+    reg.close()
+
+
+@pytest.fixture
+def tracker(db_dir: Path) -> QValueTracker:
+    """Fresh tracker backed by a temp DB."""
+    tr = QValueTracker(db_path=db_dir / "tracker.db")
+    yield tr
+    tr.close()
+
+
+@pytest.fixture
+def graph() -> SkillDependencyGraph:
+    """Empty dependency graph."""
+    return SkillDependencyGraph()
+
+
+@pytest.fixture
+def memory(db_dir: Path) -> ElasticMemory:
+    """Fresh ElasticMemory backed by a temp DB."""
+    mem = ElasticMemory(db_path=db_dir / "memory.db", max_entries=100)
+    yield mem
+    mem.close()
+
+
+@pytest.fixture
+def health_monitor(
+    registry: SkillRegistry, tracker: QValueTracker, graph: SkillDependencyGraph
+) -> HealthMonitor:
+    """Health monitor using the test registry/tracker."""
+    return HealthMonitor(registry, tracker, graph)
+
+
+@pytest.fixture
+def alert_manager(
+    registry: SkillRegistry,
+    tracker: QValueTracker,
+    health_monitor: HealthMonitor,
+    db_dir: Path,
+) -> AlertManager:
+    """Fresh AlertManager backed by a temp DB."""
+    am = AlertManager(
+        registry, tracker, health_monitor,
+        db_path=str(db_dir / "alerts.db"),
+    )
+    yield am
+    am.close()
+
+
+# -----------------------------------------------------------------------
+# Helpers
+# -----------------------------------------------------------------------
+
+
+def _register_sample_skills(registry: SkillRegistry) -> list[Skill]:
+    """Create sample skills for testing."""
+    s1 = registry.register_skill(
+        name="web-scraper",
+        tier1_metadata="Scrapes web content",
+        tier2_core="1. Fetch URL\n2. Parse HTML\n3. Extract data",
+        tags=["web", "scraping"],
+        skill_id="skill-1",
+    )
+    s2 = registry.register_skill(
+        name="api-fetcher",
+        tier1_metadata="Fetches API data",
+        tier2_core="1. Authenticate\n2. Send request\n3. Parse JSON",
+        tags=["api", "data"],
+        skill_id="skill-2",
+    )
+    registry.update_skill("skill-1", {"lifecycle": SkillLifecycle.ACTIVE})
+    registry.update_skill("skill-2", {"lifecycle": SkillLifecycle.ACTIVE})
+    return [s1, s2]
+
+
+def _record_outcomes(
+    tracker: QValueTracker,
+    skill_id: str,
+    count: int = 10,
+    success_rate: float = 0.7,
+) -> None:
+    """Record a series of outcomes for testing."""
+    successes = int(count * success_rate)
+    for i in range(count):
+        success = i < successes
+        tracker.record_outcome(Outcome(
+            skill_id=skill_id,
+            success=success,
+            latency_ms=100.0 + i * 10,
+            tokens_used=500 + i * 50,
+        ))
+
+
+# =======================================================================
+# TestElasticMemory
+# =======================================================================
+
+
+class TestElasticMemory:
+    """Tests for the ElasticMemory module."""
+
+    def test_remember_and_recall(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Remembering a memory and recalling by skill_id should return it."""
+        entry = memory.remember(
+            skill_id="skill-1",
+            context="Scraped example.com for product data",
+            outcome="Successfully extracted 50 products",
+            importance=0.8,
+            metadata={"tokens": 1200, "latency_ms": 340.5},
+        )
+
+        assert isinstance(entry, MemoryEntry)
+        assert entry.skill_id == "skill-1"
+        assert entry.importance == 0.8
+        assert entry.metadata["tokens"] == 1200
+
+        results = memory.recall(skill_id="skill-1")
+        assert len(results) == 1
+        assert results[0].id == entry.id
+        # Access count should have been incremented
+        assert results[0].access_count == 1
+
+    def test_recall_with_query(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Recall with keywords should rank matching memories higher."""
+        memory.remember(
+            skill_id="skill-1",
+            context="Scraped weather data from API",
+            outcome="Got temperature and humidity readings",
+            importance=0.5,
+        )
+        memory.remember(
+            skill_id="skill-1",
+            context="Parsed email headers",
+            outcome="Extracted sender and subject lines",
+            importance=0.5,
+        )
+        memory.remember(
+            skill_id="skill-1",
+            context="Scraped product prices from e-commerce site",
+            outcome="Extracted 100 product entries",
+            importance=0.5,
+        )
+
+        # Query for "scraped" — should match 1st and 3rd memories
+        results = memory.recall(skill_id="skill-1", query="scraped products")
+        assert len(results) >= 1
+        # The most relevant (product scraping) should be first
+        assert "product" in results[0].context.lower() or "scraped" in results[0].context.lower()
+
+    def test_recall_all_skills(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Recall without skill_id should search across all skills."""
+        memory.remember(skill_id="a", context="alpha task", outcome="ok", importance=0.3)
+        memory.remember(skill_id="b", context="beta task", outcome="ok", importance=0.7)
+        memory.remember(skill_id="c", context="gamma task", outcome="ok", importance=0.5)
+
+        results = memory.recall(limit=10)
+        assert len(results) == 3
+        # Default ordering: importance descending
+        assert results[0].skill_id == "b"
+
+    def test_recall_min_importance(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Recall with min_importance should filter out low-importance entries."""
+        memory.remember(skill_id="x", context="c", outcome="o", importance=0.1)
+        memory.remember(skill_id="x", context="c", outcome="o", importance=0.9)
+
+        results = memory.recall(min_importance=0.5)
+        assert all(r.importance >= 0.5 for r in results)
+
+    def test_get_memory(self, memory: ElasticMemory) -> None:
+        """get_memory should return a single entry by ID."""
+        entry = memory.remember(skill_id="s", context="c", outcome="o")
+        fetched = memory.get_memory(entry.id)
+        assert fetched is not None
+        assert fetched.id == entry.id
+
+        assert memory.get_memory("nonexistent") is None
+
+    def test_forget_by_skill(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Forget should remove memories matching criteria."""
+        memory.remember(skill_id="a", context="c", outcome="o", importance=0.2)
+        memory.remember(skill_id="a", context="c", outcome="o", importance=0.9)
+        memory.remember(skill_id="b", context="c", outcome="o", importance=0.2)
+
+        # Forget low-importance memories for skill "a" only
+        deleted = memory.forget(skill_id="a", max_importance=0.5)
+        assert deleted == 1
+        assert memory.count(skill_id="a") == 1
+
+    def test_forget_by_age(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Forget with older_than_days should remove old memories."""
+        entry = memory.remember(skill_id="s", context="c", outcome="o", importance=0.5)
+        # Manually backdate the entry
+        old_time = (datetime.now(timezone.utc) - timedelta(days=100)).isoformat()
+        memory._conn.execute(
+            "UPDATE memories SET created_at = ? WHERE id = ?",
+            (old_time, entry.id),
+        )
+        memory._conn.commit()
+
+        deleted = memory.forget(older_than_days=30, max_importance=0.5)
+        assert deleted == 1
+        assert memory.count() == 0
+
+    def test_forget_returns_zero_when_no_match(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Forget should return 0 when nothing matches."""
+        memory.remember(skill_id="s", context="c", outcome="o", importance=0.9)
+        deleted = memory.forget(max_importance=0.1)
+        assert deleted == 0
+
+    def test_consolidate_merges_similar(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Consolidation should merge memories with high keyword overlap."""
+        memory.remember(
+            skill_id="s",
+            context="Fetch weather data from API endpoint",
+            outcome="Got temperature humidity data successfully",
+            importance=0.3,
+        )
+        memory.remember(
+            skill_id="s",
+            context="Fetch weather data from API endpoint",
+            outcome="Got temperature humidity readings successfully",
+            importance=0.7,
+        )
+        memory.remember(
+            skill_id="s",
+            context="Parse email headers for spam detection",
+            outcome="Identified 5 spam emails in inbox",
+            importance=0.5,
+        )
+
+        removed = memory.consolidate("s")
+        assert removed >= 1
+        remaining = memory.count(skill_id="s")
+        assert remaining < 3
+
+    def test_consolidate_no_similar(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Consolidation should return 0 when memories are distinct."""
+        memory.remember(skill_id="s", context="alpha beta gamma", outcome="ok")
+        memory.remember(skill_id="s", context="delta epsilon zeta", outcome="ok")
+
+        removed = memory.consolidate("s")
+        assert removed == 0
+
+    def test_consolidate_single_entry(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Consolidation with a single memory should return 0."""
+        memory.remember(skill_id="s", context="only one", outcome="ok")
+        assert memory.consolidate("s") == 0
+
+    def test_auto_compact_evicts_low_score(
+        self, db_dir: Path
+    ) -> None:
+        """auto_compact should evict lowest-retention entries."""
+        mem = ElasticMemory(db_path=db_dir / "compact.db", max_entries=5)
+
+        for i in range(8):
+            mem.remember(
+                skill_id=f"skill-{i}",
+                context=f"task number {i}",
+                outcome=f"outcome {i}",
+                importance=i / 10.0,
+            )
+
+        evicted = mem.auto_compact()
+        assert evicted == 3
+        assert mem.count() == 5
+
+        mem.close()
+
+    def test_auto_compact_no_eviction(
+        self, memory: ElasticMemory
+    ) -> None:
+        """auto_compact should return 0 when under cap."""
+        memory.remember(skill_id="s", context="c", outcome="o")
+        evicted = memory.auto_compact(max_entries=1000)
+        assert evicted == 0
+
+    def test_get_memory_stats_global(
+        self, memory: ElasticMemory
+    ) -> None:
+        """get_memory_stats should return global stats without skill_id."""
+        memory.remember(skill_id="a", context="c", outcome="o", importance=0.6)
+        memory.remember(skill_id="b", context="c", outcome="o", importance=0.8)
+
+        stats = memory.get_memory_stats()
+        assert stats["total_memories"] == 2
+        assert stats["avg_importance"] == pytest.approx(0.7, abs=0.01)
+        assert len(stats["skills"]) == 2
+
+    def test_get_memory_stats_per_skill(
+        self, memory: ElasticMemory
+    ) -> None:
+        """get_memory_stats with skill_id should scope to that skill."""
+        memory.remember(skill_id="a", context="c", outcome="o", importance=0.5)
+        memory.remember(skill_id="b", context="c", outcome="o", importance=0.9)
+
+        stats = memory.get_memory_stats(skill_id="a")
+        assert stats["total_memories"] == 1
+        assert stats["skills"] == ["a"]
+
+    def test_count(self, memory: ElasticMemory) -> None:
+        """Count should return accurate totals."""
+        assert memory.count() == 0
+        memory.remember(skill_id="s", context="c", outcome="o")
+        assert memory.count() == 1
+        assert memory.count(skill_id="s") == 1
+        assert memory.count(skill_id="other") == 0
+
+    def test_importance_clamped(
+        self, memory: ElasticMemory
+    ) -> None:
+        """Importance should be clamped to [0, 1]."""
+        entry = memory.remember(skill_id="s", context="c", outcome="o", importance=1.5)
+        assert entry.importance == 1.0
+
+        entry = memory.remember(skill_id="s", context="c", outcome="o", importance=-0.5)
+        assert entry.importance == 0.0
+
+
+# =======================================================================
+# TestAlertManager
+# =======================================================================
+
+
+class TestAlertManager:
+    """Tests for the AlertManager module."""
+
+    def test_add_and_get_rules(
+        self, alert_manager: AlertManager
+    ) -> None:
+        """Adding a rule should make it retrievable."""
+        rule = AlertRule(
+            id="rule-1",
+            name="Low Q-value",
+            rule_type=AlertRuleType.THRESHOLD,
+            metric="q_value",
+            threshold=0.3,
+            severity=AlertSeverity.WARNING,
+            description="Alert when Q-value drops below 0.3",
+        )
+        alert_manager.add_rule(rule)
+
+        rules = alert_manager.get_rules()
+        assert len(rules) == 1
+        assert rules[0].name == "Low Q-value"
+
+        fetched = alert_manager.get_rule("rule-1")
+        assert fetched is not None
+        assert fetched.threshold == 0.3
+
+    def test_add_rule_generates_id(
+        self, alert_manager: AlertManager
+    ) -> None:
+        """add_rule should generate an ID if one is empty."""
+        rule = AlertRule(
+            id="",
+            name="Auto-ID rule",
+            metric="success_rate",
+            threshold=0.5,
+        )
+        result = alert_manager.add_rule(rule)
+        assert result.id != ""
+
+    def test_remove_rule(
+        self, alert_manager: AlertManager
+    ) -> None:
+        """Removing a rule should return True and remove it from memory."""
+        rule = AlertRule(id="r1", name="test", metric="q_value", threshold=0.3)
+        alert_manager.add_rule(rule)
+
+        assert alert_manager.remove_rule("r1") is True
+        assert len(alert_manager.get_rules()) == 0
+
+    def test_remove_nonexistent_rule(
+        self, alert_manager: AlertManager
+    ) -> None:
+        """Removing a nonexistent rule should return False."""
+        assert alert_manager.remove_rule("nonexistent") is False
+
+    def test_check_alerts_fires_on_low_q(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """A threshold rule should fire when Q-value is below threshold."""
+        _register_sample_skills(registry)
+
+        # Record failures to lower Q-value
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        # Add a rule: Q-value < 0.4
+        alert_manager.add_rule(AlertRule(
+            id="q-rule",
+            name="Low Q-value alert",
+            rule_type=AlertRuleType.THRESHOLD,
+            metric="q_value",
+            threshold=0.4,
+            severity=AlertSeverity.CRITICAL,
+            cooldown_minutes=0,
+        ))
+
+        new_alerts = alert_manager.check_alerts()
+
+        # At least one alert should have fired for skill-1
+        skill_alerts = [a for a in new_alerts if a.skill_id == "skill-1"]
+        assert len(skill_alerts) >= 1
+        assert skill_alerts[0].severity == AlertSeverity.CRITICAL
+        assert skill_alerts[0].status == AlertStatus.ACTIVE
+
+    def test_check_alerts_no_fire_above_threshold(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """No alert should fire when metric is above threshold."""
+        _register_sample_skills(registry)
+
+        # Record successes to boost Q-value
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=True, latency_ms=100, tokens_used=50
+            ))
+        tracker.td_lambda_update("skill-1", reward=1.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="q-rule",
+            name="Low Q-value",
+            metric="q_value",
+            threshold=0.1,  # Very low threshold — won't fire
+            cooldown_minutes=0,
+        ))
+
+        new_alerts = alert_manager.check_alerts()
+        assert len(new_alerts) == 0
+
+    def test_check_alerts_respects_cooldown(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """Alerts should not re-fire within the cooldown period."""
+        _register_sample_skills(registry)
+
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="cd-rule",
+            name="Cooldown test",
+            metric="q_value",
+            threshold=0.9,  # High threshold so it always fires
+            cooldown_minutes=60,
+        ))
+
+        # First check should fire
+        first = alert_manager.check_alerts()
+        assert len([a for a in first if a.skill_id == "skill-1"]) >= 1
+
+        # Second check immediately — should be in cooldown
+        second = alert_manager.check_alerts()
+        assert len([a for a in second if a.skill_id == "skill-1"]) == 0
+
+    def test_check_alerts_disabled_rule(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+    ) -> None:
+        """Disabled rules should not fire."""
+        _register_sample_skills(registry)
+
+        alert_manager.add_rule(AlertRule(
+            id="disabled",
+            name="Disabled rule",
+            metric="q_value",
+            threshold=0.99,
+            enabled=False,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        assert len(alerts) == 0
+
+    def test_check_alerts_specific_skill(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """A rule with skill_id set should only monitor that skill."""
+        _register_sample_skills(registry)
+
+        # Lower Q-value for skill-1 only
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="specific",
+            name="Skill-1 only",
+            skill_id="skill-1",
+            metric="q_value",
+            threshold=0.4,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        skill_alerts = [a for a in alerts if a.skill_id == "skill-1"]
+        assert len(skill_alerts) >= 1
+        # Should NOT fire for skill-2
+        s2_alerts = [a for a in alerts if a.skill_id == "skill-2"]
+        assert len(s2_alerts) == 0
+
+    def test_alert_lifecycle(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """Alerts should progress through ACTIVE → ACKNOWLEDGED → RESOLVED."""
+        _register_sample_skills(registry)
+
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="lifecycle",
+            name="Lifecycle test",
+            metric="q_value",
+            threshold=0.4,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        assert len(alerts) >= 1
+        alert_id = alerts[0].id
+
+        # Acknowledge
+        acked = alert_manager.acknowledge_alert(alert_id)
+        assert acked is not None
+        assert acked.status == AlertStatus.ACKNOWLEDGED
+        assert acked.acknowledged_at is not None
+
+        # Resolve
+        resolved = alert_manager.resolve_alert(alert_id)
+        assert resolved is not None
+        assert resolved.status == AlertStatus.RESOLVED
+        assert resolved.resolved_at is not None
+
+    def test_acknowledge_nonexistent(
+        self, alert_manager: AlertManager
+    ) -> None:
+        """Acknowledging a nonexistent alert should return None."""
+        assert alert_manager.acknowledge_alert("nope") is None
+
+    def test_resolve_nonexistent(
+        self, alert_manager: AlertManager
+    ) -> None:
+        """Resolving a nonexistent alert should return None."""
+        assert alert_manager.resolve_alert("nope") is None
+
+    def test_get_active_alerts(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """get_active_alerts should return only active alerts."""
+        _register_sample_skills(registry)
+
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="active-test",
+            name="Active test",
+            metric="q_value",
+            threshold=0.4,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        assert len(alerts) >= 1
+
+        active = alert_manager.get_active_alerts()
+        assert len(active) >= 1
+        assert all(a.status == AlertStatus.ACTIVE for a in active)
+
+        # Resolve one and check it disappears from active
+        alert_manager.resolve_alert(active[0].id)
+        active_after = alert_manager.get_active_alerts()
+        assert len(active_after) == len(active) - 1
+
+    def test_get_alert_history(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """get_alert_history should return all alerts regardless of status."""
+        _register_sample_skills(registry)
+
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="history-test",
+            name="History test",
+            metric="q_value",
+            threshold=0.4,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        assert len(alerts) >= 1
+
+        history = alert_manager.get_alert_history()
+        assert len(history) >= 1
+
+    def test_callback_invoked(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """Registered callbacks should be invoked when alerts fire."""
+        _register_sample_skills(registry)
+
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        captured: list[Alert] = []
+
+        def _on_alert(alert: Alert) -> None:
+            captured.append(alert)
+
+        alert_manager.register_callback(_on_alert)
+
+        alert_manager.add_rule(AlertRule(
+            id="cb-test",
+            name="Callback test",
+            metric="q_value",
+            threshold=0.4,
+            cooldown_minutes=0,
+        ))
+
+        alert_manager.check_alerts()
+        assert len(captured) >= 1
+        assert isinstance(captured[0], Alert)
+
+    def test_unregister_callback(
+        self, alert_manager: AlertManager
+    ) -> None:
+        """Unregistering a callback should return True."""
+        def _cb(a: Alert) -> None:
+            pass
+
+        alert_manager.register_callback(_cb)
+        assert alert_manager.unregister_callback(_cb) is True
+        assert alert_manager.unregister_callback(_cb) is False
+
+    def test_alert_summary(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """get_alert_summary should return a complete summary dict."""
+        _register_sample_skills(registry)
+
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="sum-1", name="rule 1", metric="q_value", threshold=0.4, cooldown_minutes=0,
+        ))
+        alert_manager.add_rule(AlertRule(
+            id="sum-2", name="rule 2", metric="success_rate", threshold=0.1,
+            enabled=False, cooldown_minutes=0,
+        ))
+
+        alert_manager.check_alerts()
+
+        summary = alert_manager.get_alert_summary()
+        assert summary["total_rules"] == 2
+        assert summary["enabled_rules"] == 1
+        assert summary["active_alerts"] >= 0
+        assert "by_severity" in summary
+
+    def test_success_rate_rule(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """A success_rate rule should fire when success rate is low."""
+        _register_sample_skills(registry)
+
+        for _ in range(20):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+
+        alert_manager.add_rule(AlertRule(
+            id="sr-rule",
+            name="Low success rate",
+            metric="success_rate",
+            threshold=0.5,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        skill_alerts = [a for a in alerts if a.skill_id == "skill-1"]
+        assert len(skill_alerts) >= 1
+
+    def test_failure_rate_rule(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """A failure_rate rule should fire when failure_rate < threshold (not high failure rate)."""
+        _register_sample_skills(registry)
+
+        # All failures → failure_rate = 1.0
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+
+        alert_manager.add_rule(AlertRule(
+            id="fr-rule",
+            name="High failure rate",
+            skill_id="skill-1",
+            metric="failure_rate",
+            threshold=0.9,  # fires when failure_rate < 0.9 → 1.0 is NOT < 0.9
+            cooldown_minutes=0,
+        ))
+
+        # The threshold rule fires when metric < threshold
+        # failure_rate = 1.0, threshold = 0.9 → 1.0 < 0.9 is False → won't fire
+        alerts = alert_manager.check_alerts()
+        assert len(alerts) == 0
+
+    def test_rule_persistence(
+        self,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+        health_monitor: HealthMonitor,
+        db_dir: Path,
+    ) -> None:
+        """Rules should survive creating a new AlertManager instance."""
+        db_path = str(db_dir / "persist.db")
+
+        am1 = AlertManager(registry, tracker, health_monitor, db_path=db_path)
+        am1.add_rule(AlertRule(
+            id="persist-1",
+            name="Persistent rule",
+            metric="q_value",
+            threshold=0.3,
+        ))
+        am1.close()
+
+        am2 = AlertManager(registry, tracker, health_monitor, db_path=db_path)
+        rules = am2.get_rules()
+        assert len(rules) == 1
+        assert rules[0].id == "persist-1"
+        assert rules[0].threshold == 0.3
+        am2.close()
+
+    def test_health_score_rule(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """A health_score rule should use the HealthMonitor."""
+        _register_sample_skills(registry)
+
+        # Record failures to create a critical health state
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="health-rule",
+            name="Low health",
+            metric="health_score",
+            threshold=0.5,
+            severity=AlertSeverity.WARNING,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        skill_alerts = [a for a in alerts if a.skill_id == "skill-1"]
+        assert len(skill_alerts) >= 1
+
+    def test_alert_message_format(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """Alert messages should contain key information."""
+        _register_sample_skills(registry)
+
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="msg-test",
+            name="Message format test",
+            metric="q_value",
+            threshold=0.4,
+            severity=AlertSeverity.WARNING,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        assert len(alerts) >= 1
+        msg = alerts[0].message
+        assert "WARNING" in msg
+        assert "Message format test" in msg
+        assert "q_value" in msg
+
+    def test_alert_metadata(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """Alert metadata should contain skill_name and rule info."""
+        _register_sample_skills(registry)
+
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="meta-test",
+            name="Metadata test",
+            metric="q_value",
+            threshold=0.4,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        assert len(alerts) >= 1
+        meta = alerts[0].metadata
+        assert "skill_name" in meta
+        assert meta["metric"] == "q_value"
+        assert meta["rule_type"] == "threshold"
+
+    def test_trend_rule_type(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """Trend-type rules should fire when metric < threshold."""
+        _register_sample_skills(registry)
+
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="trend-rule",
+            name="Declining trend",
+            rule_type=AlertRuleType.TREND,
+            metric="q_value",
+            threshold=0.5,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        skill_alerts = [a for a in alerts if a.skill_id == "skill-1"]
+        assert len(skill_alerts) >= 1
+
+    def test_anomaly_rule_type(
+        self,
+        alert_manager: AlertManager,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+    ) -> None:
+        """Anomaly-type rules should fire when metric < threshold."""
+        _register_sample_skills(registry)
+
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        alert_manager.add_rule(AlertRule(
+            id="anomaly-rule",
+            name="Anomaly detected",
+            rule_type=AlertRuleType.ANOMALY,
+            metric="q_value",
+            threshold=0.5,
+            cooldown_minutes=0,
+        ))
+
+        alerts = alert_manager.check_alerts()
+        skill_alerts = [a for a in alerts if a.skill_id == "skill-1"]
+        assert len(skill_alerts) >= 1
+
+
+# =======================================================================
+# Integration tests
+# =======================================================================
+
+
+class TestPhase5aIntegration:
+    """Integration tests combining ElasticMemory and AlertManager."""
+
+    def test_memory_and_alerts_together(
+        self,
+        db_dir: Path,
+        registry: SkillRegistry,
+        tracker: QValueTracker,
+        health_monitor: HealthMonitor,
+    ) -> None:
+        """ElasticMemory and AlertManager should work side by side."""
+        memory = ElasticMemory(db_path=db_dir / "mem.db")
+        alert_mgr = AlertManager(
+            registry, tracker, health_monitor,
+            db_path=str(db_dir / "alert.db"),
+        )
+
+        # Register a skill
+        _register_sample_skills(registry)
+
+        # Record outcomes that will trigger alerts
+        for _ in range(10):
+            tracker.record_outcome(Outcome(
+                skill_id="skill-1", success=False, latency_ms=500, tokens_used=200
+            ))
+        tracker.td_lambda_update("skill-1", reward=0.0, alpha=0.5)
+
+        # Store memory of the failure
+        mem = memory.remember(
+            skill_id="skill-1",
+            context="Attempted web scraping of example.com",
+            outcome="Failed: connection timeout after 500ms",
+            importance=0.6,
+            metadata={"latency_ms": 500, "success": False},
+        )
+        assert mem.skill_id == "skill-1"
+
+        # Set up an alert rule
+        alert_mgr.add_rule(AlertRule(
+            id="integ-1",
+            name="Integration test alert",
+            metric="q_value",
+            threshold=0.4,
+            cooldown_minutes=0,
+        ))
+
+        # Check alerts
+        alerts = alert_mgr.check_alerts()
+        assert len(alerts) >= 1
+
+        # Recall the memory
+        memories = memory.recall(skill_id="skill-1")
+        assert len(memories) == 1
+        assert "timeout" in memories[0].outcome
+
+        # Both systems should have their data
+        assert memory.count() == 1
+        assert alert_mgr.get_alert_summary()["active_alerts"] >= 1
+
+        memory.close()
+        alert_mgr.close()
diff --git a/tests/test_phase6a.py b/tests/test_phase6a.py
new file mode 100644
index 0000000..e371b59
--- /dev/null
+++ b/tests/test_phase6a.py
@@ -0,0 +1,1434 @@
+"""Tests for Phase 6a modules: Marketplace, Observability, Async Support, Versioning.
+
+Uses pytest with tmp_path for DB isolation.  Tests cover:
+- Marketplace: publish, search, rate, install, deprecate
+- Observability: tracing spans, metrics collection, structured logging
+- Async Support: async registry, tracker, loader operations
+- Versioning: semantic version parsing, bumping, history, rollback, diff
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, Generator
+
+import pytest
+
+from skillforge.core.registry import Skill, SkillLifecycle, SkillRegistry
+from skillforge.core.tracker import Outcome, QValueTracker
+from skillforge.core.loader import ProgressiveLoader
+
+from skillforge.marketplace.registry import (
+    MarketplaceRegistry,
+    MarketplaceEntry,
+    MarketplaceStatus,
+    InstallRecord,
+)
+from skillforge.marketplace.publisher import SkillPublisher, PublishResult
+from skillforge.marketplace.installer import SkillInstaller, InstallResult
+
+from skillforge.observability.tracer import SkillTracer, Span, SpanStatus, TraceContext
+from skillforge.observability.metrics import MetricsCollector, MetricPoint, MetricSummary
+from skillforge.observability.logger import StructuredLogger, LogEntry, LogLevel
+
+from skillforge.async_support.async_registry import AsyncSkillRegistry
+from skillforge.async_support.async_tracker import AsyncQValueTracker
+from skillforge.async_support.async_loader import AsyncProgressiveLoader
+
+from skillforge.versioning.version_manager import (
+    VersionManager,
+    SemanticVersion,
+    VersionRecord,
+    VersionDiff,
+    ChangeType,
+)
+
+
+# -----------------------------------------------------------------------
+# Shared fixtures
+# -----------------------------------------------------------------------
+
+
+@pytest.fixture
+def db_dir(tmp_path: Path) -> Path:
+    """Create a temp directory for DB files."""
+    d = tmp_path / "db"
+    d.mkdir()
+    return d
+
+
+@pytest.fixture
+def registry(db_dir: Path) -> SkillRegistry:
+    """Fresh registry backed by a temp DB."""
+    reg = SkillRegistry(db_path=db_dir / "skills.db")
+    yield reg  # type: ignore[misc]
+    reg.close()
+
+
+@pytest.fixture
+def tracker(db_dir: Path) -> QValueTracker:
+    """Fresh tracker backed by a temp DB."""
+    tr = QValueTracker(db_path=db_dir / "tracker.db")
+    yield tr  # type: ignore[misc]
+    tr.close()
+
+
+@pytest.fixture
+def marketplace(db_dir: Path) -> MarketplaceRegistry:
+    """Fresh marketplace backed by a temp DB."""
+    mp = MarketplaceRegistry(db_path=db_dir / "marketplace.db")
+    yield mp  # type: ignore[misc]
+    mp.close()
+
+
+@pytest.fixture
+def publisher(marketplace: MarketplaceRegistry) -> SkillPublisher:
+    """Publisher wired to the test marketplace."""
+    return SkillPublisher(marketplace)
+
+
+@pytest.fixture
+def installer(marketplace: MarketplaceRegistry) -> SkillInstaller:
+    """Installer wired to the test marketplace."""
+    return SkillInstaller(marketplace)
+
+
+@pytest.fixture
+def tracer(db_dir: Path) -> SkillTracer:
+    """Fresh tracer backed by a temp DB."""
+    t = SkillTracer(db_path=db_dir / "traces.db")
+    yield t  # type: ignore[misc]
+    t.close()
+
+
+@pytest.fixture
+def metrics(db_dir: Path) -> MetricsCollector:
+    """Fresh metrics collector backed by a temp DB."""
+    m = MetricsCollector(db_path=db_dir / "metrics.db")
+    yield m  # type: ignore[misc]
+    m.close()
+
+
+@pytest.fixture
+def logger(db_dir: Path) -> StructuredLogger:
+    """Fresh structured logger backed by a temp DB."""
+    l = StructuredLogger(db_path=db_dir / "logs.db")
+    yield l  # type: ignore[misc]
+    l.close()
+
+
+@pytest.fixture
+def version_mgr(db_dir: Path) -> VersionManager:
+    """Fresh version manager backed by a temp DB."""
+    vm = VersionManager(db_path=db_dir / "versions.db")
+    yield vm  # type: ignore[misc]
+    vm.close()
+
+
+# =======================================================================
+# TestMarketplaceRegistry
+# =======================================================================
+
+
+class TestMarketplaceRegistry:
+    """Tests for the MarketplaceRegistry module."""
+
+    def test_publish_and_get(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Publishing a skill and retrieving it should work."""
+        entry = marketplace.publish(
+            skill_name="web-scraper",
+            publisher="alice",
+            description="Scrapes web content",
+            tier1_metadata="Web scraping skill",
+            tier2_core="1. Fetch URL\n2. Parse HTML",
+            tags=["web", "scraping"],
+        )
+        assert isinstance(entry, MarketplaceEntry)
+        assert entry.skill_name == "web-scraper"
+        assert entry.status == MarketplaceStatus.PUBLISHED
+        assert entry.version == "1.0.0"
+
+        fetched = marketplace.get_listing(entry.id)
+        assert fetched is not None
+        assert fetched.skill_name == "web-scraper"
+
+    def test_publish_with_custom_id(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Publishing with a custom ID should use it."""
+        entry = marketplace.publish(
+            skill_name="api-fetcher",
+            publisher="bob",
+            listing_id="custom-listing-1",
+        )
+        assert entry.id == "custom-listing-1"
+
+    def test_publish_duplicate_id_raises(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Publishing with an existing ID should raise ValueError."""
+        marketplace.publish(
+            skill_name="a", publisher="b", listing_id="dup"
+        )
+        with pytest.raises(ValueError, match="already exists"):
+            marketplace.publish(
+                skill_name="c", publisher="d", listing_id="dup"
+            )
+
+    def test_get_nonexistent_listing(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Getting a nonexistent listing should return None."""
+        assert marketplace.get_listing("nonexistent") is None
+
+    def test_update_listing(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Updating a listing should apply changes."""
+        entry = marketplace.publish(skill_name="skill-x", publisher="alice")
+        updated = marketplace.update_listing(
+            entry.id, {"description": "Updated description", "version": "1.1.0"}
+        )
+        assert updated is not None
+        assert updated.description == "Updated description"
+        assert updated.version == "1.1.0"
+
+    def test_update_nonexistent(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Updating a nonexistent listing should return None."""
+        assert marketplace.update_listing("nope", {"description": "x"}) is None
+
+    def test_list_listings_by_status(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Listing by status should filter correctly."""
+        marketplace.publish(
+            skill_name="a", publisher="p",
+            status=MarketplaceStatus.PUBLISHED,
+        )
+        marketplace.publish(
+            skill_name="b", publisher="p",
+            status=MarketplaceStatus.DEPRECATED,
+        )
+
+        published = marketplace.list_listings(status=MarketplaceStatus.PUBLISHED)
+        assert len(published) == 1
+        assert published[0].skill_name == "a"
+
+    def test_list_listings_by_publisher(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Listing by publisher should filter correctly."""
+        marketplace.publish(skill_name="a", publisher="alice")
+        marketplace.publish(skill_name="b", publisher="bob")
+
+        alice_listings = marketplace.list_listings(publisher="alice")
+        assert len(alice_listings) == 1
+        assert alice_listings[0].publisher == "alice"
+
+    def test_list_listings_by_tags(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Listing by tags should match skills with overlapping tags."""
+        marketplace.publish(skill_name="a", publisher="p", tags=["web", "api"])
+        marketplace.publish(skill_name="b", publisher="p", tags=["data", "ml"])
+
+        results = marketplace.list_listings(tags=["web"])
+        assert len(results) == 1
+        assert results[0].skill_name == "a"
+
+    def test_search(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Search should find matching skills."""
+        marketplace.publish(
+            skill_name="web-scraper",
+            publisher="p",
+            description="Scrapes websites for data",
+        )
+        marketplace.publish(
+            skill_name="email-sender",
+            publisher="p",
+            description="Sends emails",
+        )
+
+        results = marketplace.search("web")
+        assert len(results) >= 1
+        assert any("web" in r.skill_name.lower() for r in results)
+
+    def test_rate_listing(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Rating should update the average and count."""
+        entry = marketplace.publish(skill_name="a", publisher="p")
+
+        marketplace.rate_listing(entry.id, 4.0)
+        marketplace.rate_listing(entry.id, 5.0)
+
+        updated = marketplace.get_listing(entry.id)
+        assert updated is not None
+        assert updated.rating == pytest.approx(4.5, abs=0.01)
+        assert updated.rating_count == 2
+
+    def test_rate_listing_clamped(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Ratings should be clamped to [0, 5]."""
+        entry = marketplace.publish(skill_name="a", publisher="p")
+        marketplace.rate_listing(entry.id, 10.0)
+        marketplace.rate_listing(entry.id, -5.0)
+
+        updated = marketplace.get_listing(entry.id)
+        assert updated is not None
+        # First rating: (0 + 5) / 1 = 5.0 (clamped from 10)
+        # Second rating: (5 + 0) / 2 = 2.5 (clamped from -5)
+        assert updated.rating == pytest.approx(2.5, abs=0.01)
+
+    def test_record_install(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Recording an install should bump the count."""
+        entry = marketplace.publish(skill_name="a", publisher="p")
+
+        record = marketplace.record_install(
+            entry.id, installed_version="1.0.0", target_registry_id="reg-1"
+        )
+        assert isinstance(record, InstallRecord)
+        assert record.listing_id == entry.id
+
+        count = marketplace.get_install_count(entry.id)
+        assert count == 1
+
+    def test_install_history(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Install history should track multiple installs."""
+        entry = marketplace.publish(skill_name="a", publisher="p")
+
+        marketplace.record_install(entry.id, installed_version="1.0.0")
+        marketplace.record_install(entry.id, installed_version="1.1.0")
+
+        history = marketplace.get_install_history(entry.id)
+        assert len(history) == 2
+        # Most recent first
+        assert history[0].installed_version == "1.1.0"
+
+    def test_deprecate_listing(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Deprecating a listing should change its status."""
+        entry = marketplace.publish(skill_name="a", publisher="p")
+        deprecated = marketplace.deprecate_listing(entry.id)
+        assert deprecated is not None
+        assert deprecated.status == MarketplaceStatus.DEPRECATED
+
+    def test_count(
+        self, marketplace: MarketplaceRegistry
+    ) -> None:
+        """Count should return accurate totals."""
+        assert marketplace.count() == 0
+
+        marketplace.publish(skill_name="a", publisher="p")
+        marketplace.publish(skill_name="b", publisher="p")
+
+        assert marketplace.count() == 2
+        assert marketplace.count(status=MarketplaceStatus.PUBLISHED) == 2
+        assert marketplace.count(status=MarketplaceStatus.DEPRECATED) == 0
+
+
+# =======================================================================
+# TestSkillPublisher
+# =======================================================================
+
+
+class TestSkillPublisher:
+    """Tests for the SkillPublisher module."""
+
+    def test_publish_valid_skill(
+        self, publisher: SkillPublisher
+    ) -> None:
+        """Publishing a valid skill should succeed."""
+        result = publisher.publish_skill(
+            skill_name="web-scraper",
+            publisher="alice",
+            description="Scrapes web content",
+            version="1.0.0",
+            tier1_metadata="Web scraping",
+            tier2_core="Instructions here",
+            tags=["web"],
+        )
+        assert isinstance(result, PublishResult)
+        assert result.success is True
+        assert result.entry is not None
+        assert len(result.errors) == 0
+
+    def test_publish_invalid_name(
+        self, publisher: SkillPublisher
+    ) -> None:
+        """Publishing with an invalid name should fail."""
+        result = publisher.publish_skill(
+            skill_name="x",
+            publisher="alice",
+        )
+        assert result.success is False
+        assert len(result.errors) >= 1
+        assert any("name" in e.lower() for e in result.errors)
+
+    def test_publish_invalid_version(
+        self, publisher: SkillPublisher
+    ) -> None:
+        """Publishing with an invalid version should fail."""
+        result = publisher.publish_skill(
+            skill_name="web-scraper",
+            publisher="alice",
+            version="not-a-version",
+        )
+        assert result.success is False
+        assert any("version" in e.lower() for e in result.errors)
+
+    def test_publish_with_warnings(
+        self, publisher: SkillPublisher
+    ) -> None:
+        """Publishing without tags should produce warnings."""
+        result = publisher.publish_skill(
+            skill_name="web-scraper",
+            publisher="alice",
+            version="1.0.0",
+        )
+        assert result.success is True
+        assert len(result.warnings) >= 1
+        assert any("tag" in w.lower() for w in result.warnings)
+
+    def test_validate_skill_static(
+        self,
+    ) -> None:
+        """Static validation should work independently."""
+        errors, warnings = SkillPublisher.validate_skill(
+            skill_name="valid-skill",
+            publisher="alice",
+            version="1.2.3",
+            tier1_metadata="Good metadata",
+            tier2_core="Instructions",
+            tags=["a", "b"],
+        )
+        assert len(errors) == 0
+
+    def test_validate_skill_errors(
+        self,
+    ) -> None:
+        """Static validation should catch errors."""
+        errors, warnings = SkillPublisher.validate_skill(
+            skill_name="",
+            publisher="",
+            version="bad",
+        )
+        assert len(errors) >= 3  # name, publisher, version
+
+    def test_deprecate_skill(
+        self, publisher: SkillPublisher
+    ) -> None:
+        """Deprecating a published skill should work."""
+        result = publisher.publish_skill(
+            skill_name="web-scraper", publisher="alice"
+        )
+        assert result.entry is not None
+
+        success = publisher.deprecate_skill(result.entry.id)
+        assert success is True
+
+    def test_package_and_serialize(
+        self,
+    ) -> None:
+        """Packaging and serialization should round-trip."""
+        package = SkillPublisher.package_skill(
+            skill_name="test-skill",
+            tier1_metadata="Test",
+            tier2_core="Instructions",
+            tags=["test"],
+        )
+        assert package["format"] == "skillforge-package"
+
+        serialized = SkillPublisher.serialize_package(package)
+        deserialized = SkillPublisher.deserialize_package(serialized)
+        assert deserialized["skill_name"] == "test-skill"
+
+    def test_deserialize_invalid_package(
+        self,
+    ) -> None:
+        """Deserializing an invalid package should raise ValueError."""
+        with pytest.raises(ValueError, match="Unknown package format"):
+            SkillPublisher.deserialize_package('{"format": "unknown"}')
+
+
+# =======================================================================
+# TestSkillInstaller
+# =======================================================================
+
+
+class TestSkillInstaller:
+    """Tests for the SkillInstaller module."""
+
+    def test_install_published_skill(
+        self,
+        installer: SkillInstaller,
+        marketplace: MarketplaceRegistry,
+    ) -> None:
+        """Installing a published skill should succeed."""
+        entry = marketplace.publish(
+            skill_name="web-scraper",
+            publisher="alice",
+            status=MarketplaceStatus.PUBLISHED,
+        )
+
+        result = installer.install(entry.id)
+        assert isinstance(result, InstallResult)
+        assert result.success is True
+        assert result.listing_id == entry.id
+
+    def test_install_with_registry(
+        self,
+        installer: SkillInstaller,
+        marketplace: MarketplaceRegistry,
+        registry: SkillRegistry,
+    ) -> None:
+        """Installing into a local registry should work."""
+        entry = marketplace.publish(
+            skill_name="web-scraper",
+            publisher="alice",
+            tier1_metadata="Web scraping",
+            tier2_core="Instructions",
+            status=MarketplaceStatus.PUBLISHED,
+        )
+
+        result = installer.install(entry.id, registry=registry)
+        assert result.success is True
+        assert result.registry_id != ""
+
+        # Check the skill was imported
+        skill = registry.get_skill(result.registry_id, tier=1)
+        assert skill is not None
+        assert skill.name == "web-scraper"
+
+    def test_install_nonexistent(
+        self, installer: SkillInstaller
+    ) -> None:
+        """Installing a nonexistent listing should fail."""
+        result = installer.install("nonexistent")
+        assert result.success is False
+        assert any("not found" in e.lower() for e in result.errors)
+
+    def test_install_deprecated(
+        self,
+        installer: SkillInstaller,
+        marketplace: MarketplaceRegistry,
+    ) -> None:
+        """Installing a deprecated skill should fail."""
+        entry = marketplace.publish(
+            skill_name="old-skill",
+            publisher="alice",
+            status=MarketplaceStatus.DEPRECATED,
+        )
+        result = installer.install(entry.id)
+        assert result.success is False
+        assert any("deprecated" in e.lower() for e in result.errors)
+
+    def test_check_update(
+        self,
+        installer: SkillInstaller,
+        marketplace: MarketplaceRegistry,
+    ) -> None:
+        """Check update should compare versions."""
+        entry = marketplace.publish(
+            skill_name="skill-a",
+            publisher="p",
+            version="1.0.0",
+            status=MarketplaceStatus.PUBLISHED,
+        )
+
+        # Install first
+        installer.install(entry.id)
+
+        # Update the listing
+        marketplace.update_listing(entry.id, {"version": "1.1.0"})
+
+        installed, latest = installer.check_update(entry.id)
+        assert installed == "1.0.0"
+        assert latest == "1.1.0"
+
+
+# =======================================================================
+# TestSkillTracer
+# =======================================================================
+
+
+class TestSkillTracer:
+    """Tests for the SkillTracer module."""
+
+    def test_new_trace(
+        self, tracer: SkillTracer
+    ) -> None:
+        """Creating a new trace should return a context."""
+        ctx = tracer.new_trace()
+        assert isinstance(ctx, TraceContext)
+        assert ctx.trace_id != ""
+
+    def test_start_and_end_span(
+        self, tracer: SkillTracer
+    ) -> None:
+        """Starting and ending a span should persist it."""
+        span = tracer.start_span(
+            operation="load_skill",
+            skill_id="skill-1",
+        )
+        tracer.end_span(span, SpanStatus.OK)
+
+        traces = tracer.get_trace(span.trace_id)
+        assert len(traces) == 1
+        assert traces[0].operation == "load_skill"
+        assert traces[0].status == SpanStatus.OK
+
+    def test_context_manager_span(
+        self, tracer: SkillTracer
+    ) -> None:
+        """Context manager should auto-finish spans."""
+        span: Span
+        with tracer.trace_span(
+            operation="query", skill_id="skill-1"
+        ) as span:
+            span.set_attribute("query_text", "hello")
+            assert span.span_id != ""
+
+        # Span should be persisted
+        traces = tracer.get_trace(span.trace_id)
+        assert len(traces) == 1
+        assert traces[0].attributes["query_text"] == "hello"
+
+    def test_context_manager_error_span(
+        self, tracer: SkillTracer
+    ) -> None:
+        """Context manager should mark spans as ERROR on exception."""
+        caught = False
+        error_span: Span | None = None
+        try:
+            with tracer.trace_span(operation="failing_op") as span:
+                error_span = span
+                raise ValueError("boom")
+        except ValueError:
+            caught = True
+
+        assert caught
+        assert error_span is not None
+        traces = tracer.get_trace(error_span.trace_id)
+        assert len(traces) == 1
+        assert traces[0].status == SpanStatus.ERROR
+
+    def test_nested_spans(
+        self, tracer: SkillTracer
+    ) -> None:
+        """Nested spans should maintain parent-child relationships."""
+        ctx = tracer.new_trace()
+
+        parent = tracer.start_span(
+            operation="parent_op", trace_id=ctx.trace_id
+        )
+        child = tracer.start_span(
+            operation="child_op",
+            trace_id=ctx.trace_id,
+            parent_span_id=parent.span_id,
+        )
+
+        tracer.end_span(child, SpanStatus.OK)
+        tracer.end_span(parent, SpanStatus.OK)
+
+        traces = tracer.get_trace(ctx.trace_id)
+        assert len(traces) == 2
+        # Child should reference parent
+        child_span = [s for s in traces if s.operation == "child_op"][0]
+        assert child_span.parent_span_id == parent.span_id
+
+    def test_get_skill_spans(
+        self, tracer: SkillTracer
+    ) -> None:
+        """Querying by skill_id should return matching spans."""
+        for _ in range(3):
+            with tracer.trace_span(operation="op", skill_id="skill-a"):
+                pass
+        with tracer.trace_span(operation="op", skill_id="skill-b"):
+            pass
+
+        spans = tracer.get_skill_spans("skill-a")
+        assert len(spans) == 3
+
+    def test_get_slow_spans(
+        self, tracer: SkillTracer
+    ) -> None:
+        """Slow span queries should return spans above threshold."""
+        # Create a span with artificial duration by persisting directly
+        span = tracer.start_span(operation="slow_op")
+        span.finish(SpanStatus.OK)
+        # Override the duration after finish to simulate a slow span
+        span.duration_ms = 5000.0
+        tracer._persist_span(span)
+
+        fast = tracer.start_span(operation="fast_op")
+        fast.finish(SpanStatus.OK)
+        fast.duration_ms = 10.0
+        tracer._persist_span(fast)
+
+        slow = tracer.get_slow_spans(threshold_ms=1000.0)
+        assert len(slow) >= 1
+        assert slow[0].duration_ms >= 1000.0
+
+    def test_get_error_spans(
+        self, tracer: SkillTracer
+    ) -> None:
+        """Error span queries should return error spans."""
+        with tracer.trace_span(operation="ok_op"):
+            pass
+
+        err_span: Span | None = None
+        try:
+            with tracer.trace_span(operation="err_op") as span:
+                err_span = span
+                raise ValueError("test")
+        except ValueError:
+            pass
+
+        errors = tracer.get_error_spans()
+        assert len(errors) >= 1
+        assert errors[0].status == SpanStatus.ERROR
+
+    def test_get_trace_stats(
+        self, tracer: SkillTracer
+    ) -> None:
+        """Trace stats should return aggregate information."""
+        for i in range(5):
+            with tracer.trace_span(operation=f"op-{i}"):
+                pass
+
+        stats = tracer.get_trace_stats()
+        assert stats["total_spans"] >= 5
+        assert stats["unique_traces"] >= 5
+        assert "error_rate" in stats
+
+    def test_span_events(
+        self, tracer: SkillTracer
+    ) -> None:
+        """Span events should be recorded and retrievable."""
+        span = tracer.start_span(operation="op")
+        span.add_event("checkpoint_1", {"data": "value"})
+        span.add_event("checkpoint_2")
+        tracer.end_span(span)
+
+        traces = tracer.get_trace(span.trace_id)
+        assert len(traces[0].events) == 2
+        assert traces[0].events[0]["name"] == "checkpoint_1"
+
+
+# =======================================================================
+# TestMetricsCollector
+# =======================================================================
+
+
+class TestMetricsCollector:
+    """Tests for the MetricsCollector module."""
+
+    def test_record_and_retrieve(
+        self, metrics: MetricsCollector
+    ) -> None:
+        """Recording and retrieving a metric point should work."""
+        point = metrics.record(
+            name="skill.latency_ms",
+            value=150.0,
+            labels={"skill_id": "skill-1"},
+        )
+        assert isinstance(point, MetricPoint)
+        assert point.value == 150.0
+
+        points = metrics.get_metric_points("skill.latency_ms")
+        assert len(points) == 1
+        assert points[0].value == 150.0
+
+    def test_record_counter(
+        self, metrics: MetricsCollector
+    ) -> None:
+        """Counter recording should work."""
+        metrics.record_counter("skill.invocations", increment=1.0)
+        metrics.record_counter("skill.invocations", increment=1.0)
+
+        points = metrics.get_metric_points("skill.invocations")
+        assert len(points) == 2
+
+    def test_record_gauge(
+        self, metrics: MetricsCollector
+    ) -> None:
+        """Gauge recording should work."""
+        metrics.record_gauge("skill.q_value", value=0.85)
+        points = metrics.get_metric_points("skill.q_value")
+        assert len(points) == 1
+        assert points[0].value == 0.85
+
+    def test_record_timing(
+        self, metrics: MetricsCollector
+    ) -> None:
+        """Timing recording should work."""
+        metrics.record_timing("skill.load.duration_ms", duration_ms=250.0)
+        points = metrics.get_metric_points("skill.load.duration_ms")
+        assert len(points) == 1
+
+    def test_summarize(
+        self, metrics: MetricsCollector
+    ) -> None:
+        """Summarize should compute correct statistics."""
+        for v in [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]:
+            metrics.record("latency", value=float(v))
+
+        summary = metrics.summarize("latency")
+        assert isinstance(summary, MetricSummary)
+        assert summary.count == 10
+        assert summary.sum == 550.0
+        assert summary.min == 10.0
+        assert summary.max == 100.0
+        assert summary.mean == 55.0
+        assert summary.p50 == pytest.approx(55.0, abs=5.0)
+        assert summary.p95 == pytest.approx(95.5, abs=5.0)
+
+    def test_summarize_empty(
+        self, metrics: MetricsCollector
+    ) -> None:
+        """Summarizing a nonexistent metric should return zero stats."""
+        summary = metrics.summarize("nonexistent")
+        assert summary.count == 0
+
+    def test_get_histogram(
+        self, metrics: MetricsCollector
+    ) -> None:
+        """Histogram should bucket values correctly."""
+        for v in [5, 15, 25, 50, 150, 500]:
+            metrics.record("latency", value=float(v))
+
+        histogram = metrics.get_histogram(
+            "latency",
+            buckets=[10.0, 50.0, 100.0, 500.0, float("inf")],
+        )
+        assert isinstance(histogram, dict)
+        assert "<=10.0" in histogram
+        assert histogram["<=10.0"] == 1  # 5ms
+
+    def test_get_metric_names(
+        self, metrics: MetricsCollector
+    ) -> None:
+        """Should list all unique metric names."""
+        metrics.record("metric_a", value=1.0)
+        metrics.record("metric_b", value=2.0)
+        metrics.record("metric_a", value=3.0)
+
+        names = metrics.get_metric_names()
+        assert "metric_a" in names
+        assert "metric_b" in names
+        assert len(names) == 2
+
+    def test_count(
+        self, metrics: MetricsCollector
+    ) -> None:
+        """Count should return accurate totals."""
+        assert metrics.count() == 0
+
+        metrics.record("a", value=1.0)
+        metrics.record("a", value=2.0)
+        metrics.record("b", value=3.0)
+
+        assert metrics.count() == 3
+        assert metrics.count(name="a") == 2
+        assert metrics.count(name="b") == 1
+
+    def test_filter_by_labels(
+        self, metrics: MetricsCollector
+    ) -> None:
+        """Filtering by labels should work."""
+        metrics.record("latency", value=100.0, labels={"skill_id": "a"})
+        metrics.record("latency", value=200.0, labels={"skill_id": "b"})
+
+        points = metrics.get_metric_points(
+            "latency", labels={"skill_id": "a"}
+        )
+        assert len(points) == 1
+        assert points[0].value == 100.0
+
+
+# =======================================================================
+# TestStructuredLogger
+# =======================================================================
+
+
+class TestStructuredLogger:
+    """Tests for the StructuredLogger module."""
+
+    def test_log_and_retrieve(
+        self, logger: StructuredLogger
+    ) -> None:
+        """Logging and retrieving should work."""
+        entry = logger.info("Skill loaded", component="loader")
+        assert isinstance(entry, LogEntry)
+        assert entry.level == LogLevel.INFO
+        assert entry.message == "Skill loaded"
+
+        entries = logger.get_entries()
+        assert len(entries) == 1
+
+    def test_log_levels(
+        self, logger: StructuredLogger
+    ) -> None:
+        """All log levels should be supported."""
+        logger.debug("debug msg")
+        logger.info("info msg")
+        logger.warning("warning msg")
+        logger.error("error msg")
+        logger.critical("critical msg")
+
+        assert logger.count() == 5
+        assert logger.count(level=LogLevel.ERROR) == 1
+
+    def test_log_with_metadata(
+        self, logger: StructuredLogger
+    ) -> None:
+        """Logging with metadata should persist it."""
+        entry = logger.info(
+            "Skill executed",
+            component="forge",
+            skill_id="skill-1",
+            metadata={"latency_ms": 150, "tokens": 500},
+        )
+        assert entry.metadata["latency_ms"] == 150
+
+    def test_log_with_trace_correlation(
+        self, logger: StructuredLogger
+    ) -> None:
+        """Logging with trace IDs should enable correlation."""
+        entry = logger.log(
+            level=LogLevel.INFO,
+            message="Processing",
+            component="engine",
+            trace_id="trace-123",
+            span_id="span-456",
+        )
+
+        entries = logger.get_entries(trace_id="trace-123")
+        assert len(entries) == 1
+        assert entries[0].span_id == "span-456"
+
+    def test_get_entries_by_level(
+        self, logger: StructuredLogger
+    ) -> None:
+        """Filtering by level should work."""
+        logger.info("info")
+        logger.error("error")
+        logger.info("info2")
+
+        errors = logger.get_entries(level=LogLevel.ERROR)
+        assert len(errors) == 1
+        assert errors[0].message == "error"
+
+    def test_get_entries_by_component(
+        self, logger: StructuredLogger
+    ) -> None:
+        """Filtering by component should work."""
+        logger.info("msg1", component="loader")
+        logger.info("msg2", component="tracker")
+
+        loader_logs = logger.get_entries(component="loader")
+        assert len(loader_logs) == 1
+
+    def test_get_entries_by_skill(
+        self, logger: StructuredLogger
+    ) -> None:
+        """Filtering by skill_id should work."""
+        logger.info("msg1", skill_id="skill-a")
+        logger.info("msg2", skill_id="skill-b")
+        logger.info("msg3", skill_id="skill-a")
+
+        a_logs = logger.get_entries(skill_id="skill-a")
+        assert len(a_logs) == 2
+
+    def test_search(
+        self, logger: StructuredLogger
+    ) -> None:
+        """Search should find matching messages."""
+        logger.info("Skill web-scraper loaded successfully")
+        logger.info("Email sender failed with timeout")
+        logger.info("Data parser started")
+
+        results = logger.search("scraper")
+        assert len(results) >= 1
+        assert "scraper" in results[0].message.lower()
+
+    def test_get_level_counts(
+        self, logger: StructuredLogger
+    ) -> None:
+        """Level counts should be accurate."""
+        logger.info("a")
+        logger.info("b")
+        logger.error("c")
+
+        counts = logger.get_level_counts()
+        assert counts["info"] == 2
+        assert counts["error"] == 1
+
+    def test_to_dict_and_json(
+        self, logger: StructuredLogger
+    ) -> None:
+        """Serialization should work."""
+        entry = logger.info("test msg", metadata={"key": "val"})
+        d = entry.to_dict()
+        assert d["message"] == "test msg"
+        assert d["metadata"]["key"] == "val"
+
+        j = entry.to_json()
+        parsed = json.loads(j)
+        assert parsed["message"] == "test msg"
+
+
+# =======================================================================
+# TestAsyncSupport
+# =======================================================================
+
+
+class TestAsyncSupport:
+    """Tests for the async support modules."""
+
+    @pytest.mark.asyncio
+    async def test_async_registry_crud(
+        self, db_dir: Path
+    ) -> None:
+        """Async registry CRUD operations should work."""
+        async_reg = AsyncSkillRegistry(db_path=db_dir / "async_skills.db")
+
+        # Register
+        skill = await async_reg.register_skill(
+            name="async-test",
+            tier1_metadata="Async test skill",
+            tags=["async"],
+        )
+        assert skill.name == "async-test"
+
+        # Get
+        fetched = await async_reg.get_skill(skill.id)
+        assert fetched is not None
+        assert fetched.name == "async-test"
+
+        # Update
+        updated = await async_reg.update_skill(
+            skill.id, {"lifecycle": SkillLifecycle.ACTIVE}
+        )
+        assert updated is not None
+        assert updated.lifecycle == SkillLifecycle.ACTIVE
+
+        # List
+        skills = await async_reg.list_skills()
+        assert len(skills) == 1
+
+        # Search
+        results = await async_reg.search_skills("async")
+        assert len(results) == 1
+
+        await async_reg.close()
+
+    @pytest.mark.asyncio
+    async def test_async_tracker(
+        self, db_dir: Path
+    ) -> None:
+        """Async tracker operations should work."""
+        async_tracker = AsyncQValueTracker(db_path=db_dir / "async_tracker.db")
+
+        # Record outcome
+        outcome = Outcome(
+            skill_id="skill-1",
+            success=True,
+            latency_ms=100.0,
+            tokens_used=50,
+        )
+        await async_tracker.record_outcome(outcome)
+
+        # Query
+        q_value = await async_tracker.get_q_value("skill-1")
+        assert isinstance(q_value, float)
+
+        stats = await async_tracker.get_stats("skill-1")
+        assert stats["total_outcomes"] == 1
+
+        # TD update
+        new_q = await async_tracker.td_lambda_update("skill-1", reward=1.0)
+        assert new_q >= 0.5  # Should increase after success
+
+        await async_tracker.close()
+
+    @pytest.mark.asyncio
+    async def test_async_loader(
+        self, db_dir: Path
+    ) -> None:
+        """Async loader operations should work."""
+        async_reg = AsyncSkillRegistry(db_path=db_dir / "async_loader_skills.db")
+        async_tracker = AsyncQValueTracker(db_path=db_dir / "async_loader_tracker.db")
+
+        # Register skills
+        await async_reg.register_skill(
+            name="web-scraper",
+            tier1_metadata="Web scraping skill",
+            tags=["web"],
+            skill_id="skill-web",
+        )
+        await async_reg.update_skill("skill-web", {"lifecycle": SkillLifecycle.ACTIVE})
+
+        # Load
+        loader = AsyncProgressiveLoader(async_reg, async_tracker)
+        skills = await loader.load_skill("web scraping")
+        assert isinstance(skills, list)
+
+        await async_reg.close()
+        await async_tracker.close()
+
+
+# =======================================================================
+# TestVersioning
+# =======================================================================
+
+
+class TestSemanticVersion:
+    """Tests for the SemanticVersion class."""
+
+    def test_parse_basic(
+        self,
+    ) -> None:
+        """Parsing a basic version should work."""
+        v = SemanticVersion.parse("1.2.3")
+        assert v.major == 1
+        assert v.minor == 2
+        assert v.patch == 3
+        assert v.pre == ""
+        assert v.build == ""
+
+    def test_parse_with_prerelease(
+        self,
+    ) -> None:
+        """Parsing with pre-release should work."""
+        v = SemanticVersion.parse("1.0.0-alpha.1")
+        assert v.pre == "alpha.1"
+
+    def test_parse_with_build(
+        self,
+    ) -> None:
+        """Parsing with build metadata should work."""
+        v = SemanticVersion.parse("1.0.0+build.123")
+        assert v.build == "build.123"
+
+    def test_parse_full(
+        self,
+    ) -> None:
+        """Parsing a full version should work."""
+        v = SemanticVersion.parse("2.1.0-beta.2+sha.abc123")
+        assert v.major == 2
+        assert v.minor == 1
+        assert v.patch == 0
+        assert v.pre == "beta.2"
+        assert v.build == "sha.abc123"
+
+    def test_parse_invalid(
+        self,
+    ) -> None:
+        """Parsing an invalid version should raise ValueError."""
+        with pytest.raises(ValueError, match="Invalid semantic version"):
+            SemanticVersion.parse("not.a.version")
+
+    def test_str(
+        self,
+    ) -> None:
+        """String representation should be canonical."""
+        v = SemanticVersion(1, 2, 3, "beta", "build")
+        assert str(v) == "1.2.3-beta+build"
+
+    def test_comparison(
+        self,
+    ) -> None:
+        """Version comparison should follow semver rules."""
+        v1 = SemanticVersion(1, 0, 0)
+        v2 = SemanticVersion(2, 0, 0)
+        v3 = SemanticVersion(1, 1, 0)
+        v4 = SemanticVersion(1, 0, 1)
+
+        assert v1 < v2
+        assert v1 < v3
+        assert v1 < v4
+        assert v2 > v3
+        assert v3 > v4
+
+    def test_equality(
+        self,
+    ) -> None:
+        """Equal versions should be equal."""
+        v1 = SemanticVersion(1, 0, 0)
+        v2 = SemanticVersion(1, 0, 0)
+        assert v1 == v2
+
+    def test_bump_major(
+        self,
+    ) -> None:
+        """Bumping major should reset minor and patch."""
+        v = SemanticVersion(1, 5, 3)
+        bumped = v.bump(ChangeType.MAJOR)
+        assert bumped == SemanticVersion(2, 0, 0)
+
+    def test_bump_minor(
+        self,
+    ) -> None:
+        """Bumping minor should reset patch."""
+        v = SemanticVersion(1, 5, 3)
+        bumped = v.bump(ChangeType.MINOR)
+        assert bumped == SemanticVersion(1, 6, 0)
+
+    def test_bump_patch(
+        self,
+    ) -> None:
+        """Bumping patch should increment only patch."""
+        v = SemanticVersion(1, 5, 3)
+        bumped = v.bump(ChangeType.PATCH)
+        assert bumped == SemanticVersion(1, 5, 4)
+
+
+class TestVersionManager:
+    """Tests for the VersionManager module."""
+
+    def test_record_version(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Recording a version should persist it."""
+        record = version_mgr.record_version(
+            skill_id="skill-1",
+            version="1.0.0",
+            change_type=ChangeType.MINOR,
+            changelog="Initial release",
+            snapshot={"tier1_metadata": "Test skill"},
+        )
+        assert isinstance(record, VersionRecord)
+        assert record.version == "1.0.0"
+        assert record.skill_id == "skill-1"
+
+    def test_record_invalid_version(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Recording an invalid version should raise ValueError."""
+        with pytest.raises(ValueError, match="Invalid semantic version"):
+            version_mgr.record_version(
+                skill_id="skill-1",
+                version="bad-version",
+            )
+
+    def test_bump_version(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Bumping should auto-increment from the latest version."""
+        version_mgr.record_version(
+            skill_id="skill-1", version="1.0.0",
+            change_type=ChangeType.MINOR,
+        )
+
+        record = version_mgr.bump_version(
+            skill_id="skill-1",
+            change_type=ChangeType.MINOR,
+            changelog="Added feature",
+        )
+        assert record.version == "1.1.0"
+
+    def test_bump_version_from_scratch(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Bumping from scratch should start at 0.1.0."""
+        record = version_mgr.bump_version(
+            skill_id="new-skill",
+            change_type=ChangeType.MINOR,
+        )
+        assert record.version == "0.1.0"
+
+    def test_get_latest_version(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Getting the latest version should return the most recent."""
+        version_mgr.record_version(skill_id="s", version="1.0.0")
+        version_mgr.record_version(skill_id="s", version="1.1.0")
+        version_mgr.record_version(skill_id="s", version="1.2.0")
+
+        latest = version_mgr.get_latest_version("s")
+        assert latest is not None
+        assert latest.version == "1.2.0"
+
+    def test_get_latest_version_none(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Getting latest when no versions exist should return None."""
+        assert version_mgr.get_latest_version("nonexistent") is None
+
+    def test_get_version(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Getting a specific version should work."""
+        version_mgr.record_version(
+            skill_id="s", version="1.0.0",
+            snapshot={"data": "v1"},
+        )
+        version_mgr.record_version(
+            skill_id="s", version="2.0.0",
+            snapshot={"data": "v2"},
+        )
+
+        v1 = version_mgr.get_version("s", "1.0.0")
+        assert v1 is not None
+        assert v1.snapshot["data"] == "v1"
+
+    def test_get_history(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """History should return all versions, most recent first."""
+        version_mgr.record_version(skill_id="s", version="1.0.0")
+        version_mgr.record_version(skill_id="s", version="1.1.0")
+        version_mgr.record_version(skill_id="s", version="2.0.0")
+
+        history = version_mgr.get_history("s")
+        assert len(history) == 3
+        assert history[0].version == "2.0.0"
+        assert history[2].version == "1.0.0"
+
+    def test_rollback(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Rollback should create a new record with the old snapshot."""
+        version_mgr.record_version(
+            skill_id="s", version="1.0.0",
+            snapshot={"instructions": "original"},
+            changelog="Initial",
+        )
+        version_mgr.record_version(
+            skill_id="s", version="2.0.0",
+            snapshot={"instructions": "breaking change"},
+            changelog="Breaking",
+        )
+
+        rolled_back = version_mgr.rollback("s", "1.0.0")
+        assert rolled_back is not None
+        assert rolled_back.snapshot["instructions"] == "original"
+        assert "Rollback" in rolled_back.changelog
+
+    def test_rollback_nonexistent(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Rolling back to a nonexistent version should return None."""
+        assert version_mgr.rollback("s", "99.99.99") is None
+
+    def test_diff(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Diff should show additions, removals, and modifications."""
+        version_mgr.record_version(
+            skill_id="s", version="1.0.0",
+            snapshot={
+                "name": "old-name",
+                "removed_field": "gone",
+                "unchanged": "same",
+            },
+        )
+        version_mgr.record_version(
+            skill_id="s", version="2.0.0",
+            snapshot={
+                "name": "new-name",
+                "unchanged": "same",
+                "new_field": "added",
+            },
+        )
+
+        diff = version_mgr.diff("s", "1.0.0", "2.0.0")
+        assert isinstance(diff, VersionDiff)
+        assert diff.has_changes
+        assert "new_field" in diff.added
+        assert "removed_field" in diff.removed
+        assert "name" in diff.modified
+        assert diff.modified["name"]["from"] == "old-name"
+        assert diff.modified["name"]["to"] == "new-name"
+
+    def test_diff_no_changes(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Diff with identical snapshots should have no changes."""
+        snap = {"a": 1, "b": 2}
+        version_mgr.record_version(skill_id="s", version="1.0.0", snapshot=snap)
+        version_mgr.record_version(skill_id="s", version="1.0.1", snapshot=snap)
+
+        diff = version_mgr.diff("s", "1.0.0", "1.0.1")
+        assert not diff.has_changes
+        assert "no changes" in diff.summary
+
+    def test_diff_nonexistent_version(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Diffing against a nonexistent version should raise."""
+        version_mgr.record_version(skill_id="s", version="1.0.0")
+        with pytest.raises(ValueError, match="not found"):
+            version_mgr.diff("s", "1.0.0", "99.0.0")
+
+    def test_diff_latest(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """diff_latest should compare latest against a specified version."""
+        version_mgr.record_version(
+            skill_id="s", version="1.0.0",
+            snapshot={"x": 1},
+        )
+        version_mgr.record_version(
+            skill_id="s", version="2.0.0",
+            snapshot={"x": 2, "y": 3},
+        )
+
+        diff = version_mgr.diff_latest("s", "1.0.0")
+        assert diff.from_version == "1.0.0"
+        assert diff.to_version == "2.0.0"
+        assert "y" in diff.added
+        assert "x" in diff.modified
+
+    def test_get_changelog(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Changelog should return formatted version history."""
+        version_mgr.record_version(
+            skill_id="s", version="1.0.0",
+            change_type=ChangeType.MINOR,
+            changelog="Initial release",
+            author="alice",
+        )
+        version_mgr.record_version(
+            skill_id="s", version="1.1.0",
+            change_type=ChangeType.MINOR,
+            changelog="Added new feature",
+            author="bob",
+        )
+
+        changelog = version_mgr.get_changelog("s")
+        assert len(changelog) == 2
+        assert changelog[0]["version"] == "1.1.0"
+        assert changelog[0]["author"] == "bob"
+
+    def test_count(
+        self, version_mgr: VersionManager
+    ) -> None:
+        """Count should return accurate totals."""
+        assert version_mgr.count() == 0
+
+        version_mgr.record_version(skill_id="s1", version="1.0.0")
+        version_mgr.record_version(skill_id="s1", version="1.1.0")
+        version_mgr.record_version(skill_id="s2", version="1.0.0")
+
+        assert version_mgr.count() == 3
+        assert version_mgr.count(skill_id="s1") == 2
+        assert version_mgr.count(skill_id="s2") == 1
diff --git a/tests/test_phase8.py b/tests/test_phase8.py
new file mode 100644
index 0000000..b5ee76b
--- /dev/null
+++ b/tests/test_phase8.py
@@ -0,0 +1,423 @@
+"""Tests for Phase 8: Production — Resilience + Caching."""
+import sys
+import time
+import tempfile
+import os
+import threading
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
+
+import pytest
+from skillforge.core.resilience import (
+    CircuitBreaker, CircuitState, CircuitStats, CircuitOpenError,
+    RetryPolicy, Bulkhead, BulkheadStats, BulkheadFullError,
+    GracefulDegradation, ResilientExecutor, ExecutorStats,
+)
+from skillforge.core.cache import (
+    TTLCache, LRUCache, CacheStats, CachedStore, cached, cache_key,
+)
+
+
+# =====================================================================
+# Circuit Breaker Tests
+# =====================================================================
+
+class TestCircuitBreaker:
+    def test_initial_state_closed(self):
+        cb = CircuitBreaker("test")
+        stats = cb.get_stats()
+        assert stats.state == CircuitState.CLOSED
+        assert stats.failure_count == 0
+
+    def test_success_stays_closed(self):
+        cb = CircuitBreaker("test", failure_threshold=3)
+        for _ in range(10):
+            cb.call(lambda: True)
+        assert cb.get_stats().state == CircuitState.CLOSED
+
+    def test_failures_open_circuit(self):
+        cb = CircuitBreaker("test", failure_threshold=3, recovery_timeout=999)
+        for _ in range(3):
+            with pytest.raises(ValueError):
+                cb.call(self._fail)
+        assert cb.get_stats().state == CircuitState.OPEN
+
+    def test_open_rejects_calls(self):
+        cb = CircuitBreaker("test", failure_threshold=1, recovery_timeout=999)
+        with pytest.raises(ValueError):
+            cb.call(self._fail)
+        with pytest.raises(CircuitOpenError):
+            cb.call(lambda: True)
+
+    def test_half_open_after_timeout(self):
+        cb = CircuitBreaker("test", failure_threshold=1, recovery_timeout=0.1, half_open_max=1)
+        with pytest.raises(ValueError):
+            cb.call(self._fail)
+        time.sleep(0.15)
+        result = cb.call(lambda: "ok")
+        assert result == "ok"
+
+    def test_excluded_exceptions_dont_count(self):
+        cb = CircuitBreaker("test", failure_threshold=3, excluded_exceptions=(ValueError,))
+        for _ in range(5):
+            with pytest.raises(ValueError):
+                cb.call(self._fail)
+        assert cb.get_stats().state == CircuitState.CLOSED
+
+    def test_manual_reset(self):
+        cb = CircuitBreaker("test", failure_threshold=1, recovery_timeout=999)
+        with pytest.raises(ValueError):
+            cb.call(self._fail)
+        assert cb.get_stats().state == CircuitState.OPEN
+        cb.reset()
+        assert cb.get_stats().state == CircuitState.CLOSED
+
+    def test_success_resets_consecutive_failures(self):
+        cb = CircuitBreaker("test", failure_threshold=5)
+        for _ in range(3):
+            with pytest.raises(ValueError):
+                cb.call(self._fail)
+        cb.call(lambda: "ok")
+        assert cb.get_stats().consecutive_failures == 0
+
+    @staticmethod
+    def _fail():
+        raise ValueError("fail")
+
+
+# =====================================================================
+# Retry Policy Tests
+# =====================================================================
+
+class TestRetryPolicy:
+    def test_no_retry_on_success(self):
+        rp = RetryPolicy(max_attempts=3, base_delay=0.01)
+        result = rp.execute(lambda: "ok")
+        assert result == "ok"
+
+    def test_retry_on_failure(self):
+        attempts = 0
+        def flaky():
+            nonlocal attempts
+            attempts += 1
+            if attempts < 3:
+                raise RuntimeError("transient")
+            return "ok"
+
+        rp = RetryPolicy(max_attempts=5, base_delay=0.01, jitter=False)
+        result = rp.execute(flaky)
+        assert result == "ok"
+        assert attempts == 3
+
+    def test_exhausted_retries_raises(self):
+        from skillforge.core.resilience import RetryExhaustedError
+        rp = RetryPolicy(max_attempts=2, base_delay=0.01, jitter=False)
+        with pytest.raises(RetryExhaustedError):
+            rp.execute(self._always_fail)
+
+    def test_custom_retriable_exceptions(self):
+        rp = RetryPolicy(max_attempts=3, base_delay=0.01, retriable_exceptions=(TypeError,))
+        with pytest.raises(ValueError):
+            rp.execute(lambda: (_ for _ in ()).throw(ValueError("no")))
+
+    @staticmethod
+    def _always_fail():
+        raise RuntimeError("always")
+
+
+# =====================================================================
+# Bulkhead Tests
+# =====================================================================
+
+class TestBulkhead:
+    def test_admits_within_capacity(self):
+        bh = Bulkhead("test", max_concurrent=2)
+        bh.execute(lambda: "ok")
+        assert bh.get_stats().current_executions == 0
+
+    def test_rejects_at_capacity(self):
+        bh = Bulkhead("test", max_concurrent=1, max_wait=0.0)
+        barrier = threading.Event()
+
+        def blocking():
+            bh.execute(lambda: barrier.wait(2.0))
+            return "ok"
+
+        bg = threading.Thread(target=blocking)
+        bg.start()
+        time.sleep(0.05)
+
+        with pytest.raises(BulkheadFullError):
+            bh.execute(lambda: "fail")
+
+        barrier.set()
+        bg.join()
+
+    def test_stats(self):
+        bh = Bulkhead("test", max_concurrent=5)
+        for _ in range(3):
+            bh.execute(lambda: "ok")
+        stats = bh.get_stats()
+        assert stats.total_calls == 3
+
+
+# =====================================================================
+# Graceful Degradation Tests
+# =====================================================================
+
+class TestGracefulDegradation:
+    def test_primary_success(self):
+        gd = GracefulDegradation("test", fallbacks=[lambda: "backup"])
+        assert gd.execute(lambda: "primary") == "primary"
+
+    def test_fallback_to_backup(self):
+        gd = GracefulDegradation("test", fallbacks=[lambda: "backup"])
+        result = gd.execute(lambda: (_ for _ in ()).throw(RuntimeError("fail")))
+        assert result == "backup"
+
+    def test_all_fail_raises(self):
+        from skillforge.core.resilience import FallbackChainExhaustedError
+        gd = GracefulDegradation("test", fallbacks=[lambda: (_ for _ in ()).throw(RuntimeError("b"))])
+        with pytest.raises(FallbackChainExhaustedError):
+            gd.execute(lambda: (_ for _ in ()).throw(RuntimeError("a")))
+
+    def test_add_fallback(self):
+        gd = GracefulDegradation("test")
+        gd.add_fallback(lambda: "fallback1")
+        assert gd.execute(lambda: (_ for _ in ()).throw(RuntimeError("fail"))) == "fallback1"
+
+
+# =====================================================================
+# Resilient Executor Tests
+# =====================================================================
+
+class TestResilientExecutor:
+    def test_basic_execution(self):
+        re = ResilientExecutor(name="test")
+        assert re.execute(lambda: "ok") == "ok"
+
+    def test_with_retry_policy(self):
+        attempts = 0
+        def flaky():
+            nonlocal attempts
+            attempts += 1
+            if attempts < 2:
+                raise RuntimeError("transient")
+            return "ok"
+
+        re = ResilientExecutor(name="test", retry_policy=RetryPolicy(max_attempts=3, base_delay=0.01, jitter=False))
+        assert re.execute(flaky) == "ok"
+
+    def test_with_circuit_breaker(self):
+        cb = CircuitBreaker("db", failure_threshold=3)
+        re = ResilientExecutor(name="test", circuit_breaker=cb)
+        assert re.execute(lambda: "ok") == "ok"
+
+    def test_stats(self):
+        re = ResilientExecutor(name="test")
+        re.execute(lambda: "ok")
+        stats = re.get_stats()
+        assert stats.total_attempts == 1
+        assert stats.total_successes == 1
+
+
+# =====================================================================
+# TTL Cache Tests
+# =====================================================================
+
+class TestTTLCache:
+    def test_put_and_get(self):
+        cache = TTLCache(ttl_seconds=10.0)
+        cache.put("key1", "value1")
+        assert cache.get("key1") == "value1"
+
+    def test_miss_returns_none(self):
+        cache = TTLCache(ttl_seconds=10.0)
+        assert cache.get("missing") is None
+
+    def test_ttl_expiry(self):
+        cache = TTLCache(ttl_seconds=0.05)
+        cache.put("key1", "value1")
+        time.sleep(0.1)
+        assert cache.get("key1") is None
+
+    def test_invalidate(self):
+        cache = TTLCache(ttl_seconds=10.0)
+        cache.put("key1", "value1")
+        assert cache.invalidate("key1") is True
+        assert cache.get("key1") is None
+
+    def test_max_size_eviction(self):
+        cache = TTLCache(ttl_seconds=999, max_size=2)
+        cache.put("a", 1)
+        cache.put("b", 2)
+        cache.put("c", 3)  # should evict oldest
+        assert cache.get("a") is None
+        assert cache.get("b") == 2
+        assert cache.get("c") == 3
+
+    def test_stats(self):
+        cache = TTLCache(ttl_seconds=10.0)
+        cache.put("a", 1)
+        cache.get("a")  # hit
+        cache.get("b")  # miss
+        stats = cache.stats
+        assert stats.hits == 1
+        assert stats.misses == 1
+        assert stats.hit_rate == 0.5
+
+    def test_clear(self):
+        cache = TTLCache(ttl_seconds=10.0)
+        cache.put("a", 1)
+        cache.clear()
+        assert cache.get("a") is None
+
+
+# =====================================================================
+# LRU Cache Tests
+# =====================================================================
+
+class TestLRUCache:
+    def test_put_and_get(self):
+        cache = LRUCache(max_size=10)
+        cache.put("a", 1)
+        assert cache.get("a") == 1
+
+    def test_lru_eviction(self):
+        cache = LRUCache(max_size=2)
+        cache.put("a", 1)
+        cache.put("b", 2)
+        cache.put("c", 3)  # evicts "a"
+        assert cache.get("a") is None
+        assert cache.get("b") == 2
+
+    def test_access_moves_to_end(self):
+        cache = LRUCache(max_size=2)
+        cache.put("a", 1)
+        cache.put("b", 2)
+        cache.get("a")  # move "a" to end
+        cache.put("c", 3)  # evicts "b" (oldest)
+        assert cache.get("a") == 1
+        assert cache.get("b") is None
+
+
+# =====================================================================
+# Cached Store Tests
+# =====================================================================
+
+class TestCachedStore:
+    def test_fetches_on_miss(self):
+        calls = []
+        def fetch(key):
+            calls.append(key)
+            return f"value_{key}"
+
+        store = CachedStore(fetch, ttl_seconds=10.0)
+        assert store.get("x") == "value_x"
+        assert len(calls) == 1
+
+    def test_caches_on_hit(self):
+        calls = []
+        def fetch(key):
+            calls.append(key)
+            return f"value_{key}"
+
+        store = CachedStore(fetch, ttl_seconds=10.0)
+        store.get("x")
+        store.get("x")  # should be cached
+        assert len(calls) == 1
+
+
+# =====================================================================
+# Cached Decorator Tests
+# =====================================================================
+
+class TestCachedDecorator:
+    def test_caches_result(self):
+        calls = []
+        @cached(ttl_seconds=10.0)
+        def expensive(x):
+            calls.append(x)
+            return x * 2
+
+        assert expensive(5) == 10
+        assert expensive(5) == 10  # cached
+        assert len(calls) == 1
+
+    def test_different_args_different_cache(self):
+        calls = []
+        @cached(ttl_seconds=10.0)
+        def expensive(x):
+            calls.append(x)
+            return x * 2
+
+        assert expensive(5) == 10
+        assert expensive(3) == 6
+        assert len(calls) == 2
+
+
+# =====================================================================
+# Cache Key Tests
+# =====================================================================
+
+class TestCacheKey:
+    def test_deterministic(self):
+        k1 = cache_key("a", "b")
+        k2 = cache_key("a", "b")
+        assert k1 == k2
+
+    def test_different_args_different_key(self):
+        k1 = cache_key("a")
+        k2 = cache_key("b")
+        assert k1 != k2
+
+
+# =====================================================================
+# Integration Tests
+# =====================================================================
+
+class TestPhase8Integration:
+    def test_resilient_executor_with_all_layers(self):
+        cb = CircuitBreaker("db", failure_threshold=3)
+        rp = RetryPolicy(max_attempts=2, base_delay=0.01, jitter=False)
+        re = ResilientExecutor(
+            name="db-call",
+            circuit_breaker=cb,
+            retry_policy=rp,
+        )
+        assert re.execute(lambda: "ok") == "ok"
+
+    def test_cached_skill_stats(self):
+        from skillforge import SkillForge
+        import tempfile
+        db = os.path.join(tempfile.mkdtemp(), "test.db")
+        forge = SkillForge(db_path=db)
+        forge.register_skill("test", "Test Skill", tier2_core="Step 1\nStep 2")
+        forge.record_outcome("test", success=True, latency_ms=1000, tokens_used=1000)
+
+        cache = TTLCache(ttl_seconds=10.0)
+
+        def get_stats_cached(skill_id):
+            cached_val = cache.get(skill_id)
+            if cached_val:
+                return cached_val
+            stats = forge.get_skill_stats(skill_id)
+            cache.put(skill_id, stats)
+            return stats
+
+        stats1 = get_stats_cached("test")
+        stats2 = get_stats_cached("test")
+        assert stats1 == stats2
+        assert cache.stats.hits == 1
+        assert cache.stats.misses == 1
+        forge.close()
+
+    def test_resilient_executor_with_degradation(self):
+        gd = GracefulDegradation("test", fallbacks=[lambda: "degraded"])
+        re = ResilientExecutor(name="test", degradation=gd)
+        result = re.execute(lambda: "ok")
+        assert result == "ok"
+
+
+if __name__ == "__main__":
+    pytest.main([__file__, "-v"])