diff --git a/index.html b/index.html
index 944d411..4a2ffc0 100755
--- a/index.html
+++ b/index.html
@@ -92,7 +92,7 @@
Linux Kernel Ring 0 Visualization
-
+
diff --git a/kernel_ai/ml/config.py b/kernel_ai/ml/config.py
index 67a63ae..0995664 100644
--- a/kernel_ai/ml/config.py
+++ b/kernel_ai/ml/config.py
@@ -87,6 +87,22 @@ class MLConfig:
# sustained anomaly).
if_cooldown_sec: float = _env_float("KERNEL_AI_ML_IF_COOLDOWN_SEC", 15.0)
+ # --- Stage 3 (drift + auto-retrain) ---
+ # Window of recent snapshots used to measure drift (minutes).
+ drift_window_min: int = _env_int("KERNEL_AI_ML_DRIFT_WINDOW_MIN", 30)
+ # Drift trips when the live flag rate exceeds expected (contamination) by
+ # this multiple, or when the mean per-feature distribution shift (in train
+ # std units) exceeds the z threshold.
+ drift_rate_mult: float = _env_float("KERNEL_AI_ML_DRIFT_RATE_MULT", 5.0)
+ drift_feature_z: float = _env_float("KERNEL_AI_ML_DRIFT_FEATURE_Z", 3.0)
+ # Poison guard: exclude snapshots within +/- this many seconds of a
+ # high-severity anomaly from the retraining set (don't learn the attack).
+ poison_guard_sec: int = _env_int("KERNEL_AI_ML_POISON_GUARD_SEC", 120)
+ # A retrained model is rejected (kept previous) if its training flag rate is
+ # outside this sane band (degenerate model: flags nothing or everything).
+ retrain_min_flag_rate: float = _env_float("KERNEL_AI_ML_RETRAIN_MIN_FLAG", 0.001)
+ retrain_max_flag_rate: float = _env_float("KERNEL_AI_ML_RETRAIN_MAX_FLAG", 0.30)
+
@property
def alpha(self) -> float:
return 2.0 / (max(2, self.baseline_window) + 1.0)
diff --git a/kernel_ai/ml/drift.py b/kernel_ai/ml/drift.py
new file mode 100644
index 0000000..1c144a3
--- /dev/null
+++ b/kernel_ai/ml/drift.py
@@ -0,0 +1,131 @@
+"""Stage 3 drift monitor.
+
+Measures, without any labels, whether the live system has drifted away from the
+"normal" the current model was trained on. Two complementary signals:
+
+ 1. flag_rate - fraction of recent snapshots the model flags as anomalous.
+ If this sits far above the model's `contamination` for a
+ sustained period (without a real incident), normal has moved.
+ 2. feature_drift - mean per-feature shift of recent data vs the training
+ distribution, measured in training-std units (a PSI-like z).
+
+Either signal crossing its threshold marks `drifted = True`, which the retrain
+orchestrator uses to decide whether to refit.
+"""
+
+from __future__ import annotations
+
+import logging
+import statistics
+
+from kernel_ai.ml.config import MLConfig
+from kernel_ai.ml.features import FEATURE_SPECS
+from kernel_ai.ml.model import IsolationForestModel
+from kernel_ai.ml.store import fetch_recent_feature_dicts, insert_drift
+
+logger = logging.getLogger("kernel_ai.ml.drift")
+
+# Cap each feature's drift z so a single quiet, near-constant metric can't
+# dominate the aggregate (a tiny absolute move on a ~0-variance feature would
+# otherwise blow up to astronomical z).
+_MAX_FEATURE_Z = 25.0
+
+
+def _feature_drift(recent: list[dict], feature_stats: dict[str, dict]) -> tuple[float, dict]:
+ """Mean absolute shift of recent feature means vs training means, in train
+ std units. Returns (aggregate_score, per_feature_detail).
+
+ The denominator floor uses the same per-feature noise floor as the Stage 1
+ z-score (FEATURE_SPECS[...].min_std), so quiet features need a *meaningful*
+ move — not a microscopic one — to register as drift.
+ """
+ if not recent or not feature_stats:
+ return 0.0, {}
+ per_feature = {}
+ z_values = []
+ for name, st in feature_stats.items():
+ vals = [float(r.get(name, 0.0)) for r in recent]
+ if not vals:
+ continue
+ recent_mean = statistics.fmean(vals)
+ train_mean = float(st.get("mean", 0.0))
+ train_std = float(st.get("std", 0.0))
+ spec = FEATURE_SPECS.get(name)
+ noise_floor = spec.min_std if spec else 0.0
+ floor = max(train_std, noise_floor, abs(train_mean) * 0.05, 1e-6)
+ z = min(_MAX_FEATURE_Z, abs(recent_mean - train_mean) / floor)
+ per_feature[name] = round(z, 3)
+ z_values.append(z)
+ aggregate = statistics.fmean(z_values) if z_values else 0.0
+ return aggregate, per_feature
+
+
+def compute_drift(cfg: MLConfig | None = None, *, persist: bool = True) -> dict:
+ cfg = cfg or MLConfig()
+ try:
+ model = IsolationForestModel.load(cfg.model_path)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("drift: no model to compare against (%s)", exc)
+ return {"available": False, "reason": "no_model"}
+
+ recent = fetch_recent_feature_dicts(cfg.dsn, minutes=cfg.drift_window_min)
+ n = len(recent)
+ contamination = float(model.meta.get("contamination", cfg.if_contamination))
+
+ if n == 0:
+ result = {
+ "available": True, "n_recent": 0, "flag_rate": 0.0,
+ "expected_rate": contamination, "feature_drift": 0.0, "drifted": False,
+ "detail": {"reason": "no_recent_data"},
+ }
+ if persist:
+ insert_drift(cfg.dsn, result)
+ return result
+
+ matrix = [[float(r.get(name, 0.0)) for name in model.feature_names] for r in recent]
+ preds = model.model.predict(matrix)
+ flagged = sum(1 for p in preds if p == -1)
+ flag_rate = flagged / n
+
+ feature_drift, per_feature = _feature_drift(recent, model.meta.get("feature_stats", {}))
+
+ rate_drift = flag_rate > contamination * cfg.drift_rate_mult
+ dist_drift = feature_drift > cfg.drift_feature_z
+ drifted = bool(rate_drift or dist_drift)
+
+ # Surface the biggest-shifting features for explainability.
+ top = sorted(per_feature.items(), key=lambda kv: kv[1], reverse=True)[:5]
+
+ result = {
+ "available": True,
+ "n_recent": n,
+ "flag_rate": round(flag_rate, 4),
+ "expected_rate": round(contamination, 4),
+ "feature_drift": round(feature_drift, 4),
+ "drifted": drifted,
+ "detail": {
+ "rate_drift": rate_drift,
+ "dist_drift": dist_drift,
+ "rate_mult_threshold": cfg.drift_rate_mult,
+ "feature_z_threshold": cfg.drift_feature_z,
+ "top_features": dict(top),
+ },
+ }
+ if persist:
+ insert_drift(cfg.dsn, result)
+ logger.info(
+ "drift: n=%d flag_rate=%.3f (exp %.3f) feature_drift=%.2f drifted=%s",
+ n, flag_rate, contamination, feature_drift, drifted,
+ )
+ return result
+
+
+def main() -> None:
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
+ import json
+
+ print(json.dumps(compute_drift(), indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/kernel_ai/ml/retrain.py b/kernel_ai/ml/retrain.py
new file mode 100644
index 0000000..2b64f09
--- /dev/null
+++ b/kernel_ai/ml/retrain.py
@@ -0,0 +1,65 @@
+"""Stage 3 auto-retrain orchestrator (run by a systemd timer).
+
+ measure drift -> decide -> retrain on clean recent data -> register
+
+Decision:
+ * default: always retrain (the timer cadence IS the schedule).
+ * --only-if-drift: retrain only when the drift monitor trips (use with a
+ more frequent timer to react faster without needless refits).
+
+Safety:
+ * training excludes high-severity anomaly windows (poison guard), and
+ * a degenerate model (flags ~nothing/~everything) is rejected, keeping the
+ previous one. Both live in train.py; this just orchestrates and logs.
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+
+from kernel_ai.ml.config import MLConfig
+from kernel_ai.ml.drift import compute_drift
+from kernel_ai.ml import train as train_mod
+
+logger = logging.getLogger("kernel_ai.ml.retrain")
+
+
+def run(*, only_if_drift: bool, min_samples: int) -> int:
+ cfg = MLConfig()
+ drift = compute_drift(cfg, persist=True)
+
+ if only_if_drift and drift.get("available") and not drift.get("drifted"):
+ logger.info("no drift detected (flag_rate=%s) - skipping retrain", drift.get("flag_rate"))
+ return 0
+
+ try:
+ metrics = train_mod.train(
+ cfg,
+ min_samples=min_samples,
+ contamination=cfg.if_contamination,
+ trees=cfg.if_n_estimators,
+ exclude_anomalous=True,
+ enforce_guardrails=True,
+ )
+ except SystemExit as exc:
+ # Soft-skip (not enough clean data, or guardrail tripped): keep previous
+ # model and don't mark the timer run as failed.
+ logger.warning("retrain skipped: %s", exc)
+ return 0
+
+ logger.info("retrain complete: %s", metrics)
+ return 0
+
+
+def main() -> None:
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
+ parser = argparse.ArgumentParser(description="Drift-aware auto-retrain")
+ parser.add_argument("--only-if-drift", action="store_true")
+ parser.add_argument("--min-samples", type=int, default=100)
+ args = parser.parse_args()
+ raise SystemExit(run(only_if_drift=args.only_if_drift, min_samples=args.min_samples))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/kernel_ai/ml/store.py b/kernel_ai/ml/store.py
index 571c36c..c8443af 100644
--- a/kernel_ai/ml/store.py
+++ b/kernel_ai/ml/store.py
@@ -51,6 +51,17 @@
count integer NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
+
+CREATE TABLE IF NOT EXISTS ml_drift (
+ ts timestamptz NOT NULL DEFAULT now(),
+ flag_rate double precision,
+ expected_rate double precision,
+ feature_drift double precision,
+ n_recent integer,
+ drifted boolean,
+ detail jsonb
+);
+CREATE INDEX IF NOT EXISTS ml_drift_ts_idx ON ml_drift (ts);
"""
@@ -154,6 +165,78 @@ def close(self) -> None:
pass
+def fetch_recent_feature_dicts(dsn: str, *, minutes: int = 30, limit: int = 5000) -> list[dict]:
+ """Recent feature snapshots (as dicts) for drift measurement."""
+ with connect(dsn) as conn, conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT features FROM ml_feature_snapshots
+ WHERE ts > now() - make_interval(mins => %s)
+ ORDER BY ts DESC LIMIT %s
+ """,
+ (minutes, limit),
+ )
+ return [r[0] for r in cur.fetchall() if isinstance(r[0], dict)]
+
+
+def fetch_training_snapshots(
+ dsn: str,
+ *,
+ limit: int = 50000,
+ exclude_anomalous: bool = True,
+ guard_sec: int = 120,
+) -> list[dict]:
+ """Snapshots for (re)training. With ``exclude_anomalous`` we drop any
+ snapshot within +/- ``guard_sec`` of a HIGH-severity anomaly, so a real
+ incident can't poison the model's idea of "normal"."""
+ with connect(dsn) as conn, conn.cursor() as cur:
+ if exclude_anomalous:
+ cur.execute(
+ """
+ SELECT s.features
+ FROM ml_feature_snapshots s
+ WHERE NOT EXISTS (
+ SELECT 1 FROM ml_anomalies a
+ WHERE a.severity = 'high'
+ AND a.ts BETWEEN s.ts - make_interval(secs => %s)
+ AND s.ts + make_interval(secs => %s)
+ )
+ ORDER BY s.ts DESC LIMIT %s
+ """,
+ (guard_sec, guard_sec, limit),
+ )
+ else:
+ cur.execute(
+ "SELECT features FROM ml_feature_snapshots ORDER BY ts DESC LIMIT %s",
+ (limit,),
+ )
+ return [r[0] for r in cur.fetchall() if isinstance(r[0], dict)]
+
+
+def insert_drift(dsn: str, record: dict) -> None:
+ """Persist one drift measurement (best-effort)."""
+ try:
+ with connect(dsn) as conn, conn.cursor() as cur:
+ cur.execute(
+ """
+ INSERT INTO ml_drift
+ (flag_rate, expected_rate, feature_drift, n_recent, drifted, detail)
+ VALUES (%(flag_rate)s, %(expected_rate)s, %(feature_drift)s,
+ %(n_recent)s, %(drifted)s, %(detail)s)
+ """,
+ {
+ "flag_rate": record.get("flag_rate"),
+ "expected_rate": record.get("expected_rate"),
+ "feature_drift": record.get("feature_drift"),
+ "n_recent": record.get("n_recent"),
+ "drifted": record.get("drifted"),
+ "detail": Json(record.get("detail") or {}),
+ },
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning("insert_drift failed: %s", exc)
+
+
def fetch_recent_anomalies(dsn: str, *, since_seconds: int = 120, limit: int = 100) -> list[dict]:
"""Read recent anomalies for the API. Never raises on DB trouble."""
try:
diff --git a/kernel_ai/ml/train.py b/kernel_ai/ml/train.py
index ffada2e..213ea1d 100644
--- a/kernel_ai/ml/train.py
+++ b/kernel_ai/ml/train.py
@@ -23,7 +23,7 @@
from kernel_ai.ml.config import MLConfig
from kernel_ai.ml.features import FEATURE_SPECS
from kernel_ai.ml.model import IsolationForestModel
-from kernel_ai.ml.store import connect
+from kernel_ai.ml.store import fetch_training_snapshots
logger = logging.getLogger("kernel_ai.ml.train")
@@ -31,20 +31,20 @@
FEATURE_ORDER = list(FEATURE_SPECS.keys())
-def load_matrix(dsn: str, limit: int = 50000) -> list[list[float]]:
- """Load feature snapshots into a dense matrix in canonical column order."""
- with connect(dsn) as conn, conn.cursor() as cur:
- cur.execute(
- "SELECT features FROM ml_feature_snapshots ORDER BY ts DESC LIMIT %s",
- (limit,),
- )
- rows = cur.fetchall()
- matrix = []
- for (features,) in rows:
- if not isinstance(features, dict):
- continue
- matrix.append([float(features.get(name, 0.0)) for name in FEATURE_ORDER])
- return matrix
+def _dicts_to_matrix(rows: list[dict]) -> list[list[float]]:
+ return [[float(r.get(name, 0.0)) for name in FEATURE_ORDER] for r in rows]
+
+
+def _feature_stats(matrix: list[list[float]]) -> dict[str, dict]:
+ """Per-feature mean/std over the training set, stored in the model meta so
+ the drift monitor can later compare live data against the trained 'normal'."""
+ stats: dict[str, dict] = {}
+ for col, name in enumerate(FEATURE_ORDER):
+ values = [row[col] for row in matrix]
+ mean = statistics.fmean(values) if values else 0.0
+ std = statistics.pstdev(values) if len(values) > 1 else 0.0
+ stats[name] = {"mean": mean, "std": std}
+ return stats
def _percentile(values: list[float], pct: float) -> float:
@@ -55,36 +55,61 @@ def _percentile(values: list[float], pct: float) -> float:
return ordered[k]
-def train(cfg: MLConfig, *, min_samples: int, contamination: float, trees: int) -> dict:
- matrix = load_matrix(cfg.dsn)
+def train(
+ cfg: MLConfig,
+ *,
+ min_samples: int,
+ contamination: float,
+ trees: int,
+ exclude_anomalous: bool = True,
+ enforce_guardrails: bool = True,
+) -> dict:
+ rows = fetch_training_snapshots(
+ cfg.dsn,
+ exclude_anomalous=exclude_anomalous,
+ guard_sec=cfg.poison_guard_sec,
+ )
+ matrix = _dicts_to_matrix(rows)
n = len(matrix)
if n < min_samples:
raise SystemExit(
- f"Not enough samples to train: have {n}, need >= {min_samples}. "
+ f"Not enough clean samples to train: have {n}, need >= {min_samples}. "
f"Let the worker collect more (it runs every {cfg.interval_sec:.0f}s)."
)
model = IsolationForestModel(feature_names=FEATURE_ORDER)
model.fit(matrix, contamination=contamination, n_estimators=trees)
+ # Stash training distribution so drift can be measured against it later.
+ model.meta["feature_stats"] = _feature_stats(matrix)
# Evaluate on the training set: how the score is distributed and what
# fraction would be flagged (should sit near `contamination`).
scores = model.score_matrix(matrix)
preds = model.model.predict(matrix)
flagged = sum(1 for p in preds if p == -1)
+ flag_rate = flagged / n if n else 0.0
metrics = {
"n_samples": float(n),
"n_features": float(len(FEATURE_ORDER)),
- "flag_rate": flagged / n if n else 0.0,
+ "flag_rate": flag_rate,
"score_mean": statistics.fmean(scores) if scores else 0.0,
"score_p95": _percentile(scores, 95),
"score_max": max(scores) if scores else 0.0,
+ "excluded_anomalous": 1.0 if exclude_anomalous else 0.0,
}
+ # Guardrail: refuse to promote a degenerate model (flags ~nothing or
+ # ~everything). The previously saved artifact is left untouched.
+ if enforce_guardrails and not (cfg.retrain_min_flag_rate <= flag_rate <= cfg.retrain_max_flag_rate):
+ raise SystemExit(
+ f"Refusing to save model: flag_rate={flag_rate:.3f} outside sane band "
+ f"[{cfg.retrain_min_flag_rate}, {cfg.retrain_max_flag_rate}]. Keeping previous model."
+ )
+
# Persist the artifact the worker loads (independent of MLflow availability).
os.makedirs(os.path.dirname(cfg.model_path), exist_ok=True)
model.save(cfg.model_path)
- logger.info("saved model artifact -> %s", cfg.model_path)
+ logger.info("saved model artifact -> %s (flag_rate=%.3f, n=%d)", cfg.model_path, flag_rate, n)
# MLflow tracking + registry (best-effort: training must not hard-fail if
# MLflow has a hiccup, the artifact is already saved above).