# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""On-device training orchestration (Stage 4, gated by ENABLE_ML_TRAINING).

Gathers the user's own labelled cycles, derives training labels from data the
integration already has, fits NumPy-only logistic heads with :mod:`.trainer`,
and promotes a retrained model over the shipped baseline only when it is at
least as good on a held-out split. Nothing here runs unless the training loop
(behind the feature flag + per-device opt-in) invokes it.

Label sources (no manual labelling required to start):
  * end detector  - from trace geometry: a completed cycle's final low-power
    event is a true end (positive); earlier pauses that resumed are non-ends.
  * quality model - from cycle status + optional ML-Lab review labels: clean
    completed / "good" / "golden" -> not a problem; force_stopped / interrupted
    / "bad" / "unusable" -> a problem.
  * live_match    - from match-ranking-history snapshots: :func:`_live_match_dataset`
    labels each snapshot 1/0 by comparing its top-1 candidate to the confirmed
    profile (wired into :func:`train_from_cycles` via ``ranking_history``).
"""
from __future__ import annotations

import logging
from typing import Any

import numpy as np

_LOGGER = logging.getLogger(__name__)

from ..const import (
    DEFAULT_DEFER_FINISH_CONFIDENCE,
    ML_MATCH_COMMIT_THRESHOLD,
    ML_QUALITY_SUSPICIOUS_THRESHOLD,
    ML_TRAINING_AUC_MARGIN,
    ML_TRAINING_BACC_MARGIN,
    ML_TRAINING_MIN_POSITIVES,
    ML_TRAINING_MIN_REGRESSION_ROWS,
    ML_TRAINING_REGRESSION_MARGIN,
)
from . import trainer as T

# Capability -> (embedded module name, target label). Mirrors engine._MODEL_MODULES.
# The target label MUST match each baseline module's MODEL_TARGET (and
# promoted_manifest.json) so a promoted on-device spec records the same target as the
# shipped baseline it replaces.
_CAPABILITIES = {
    "end": ("cycle_end_detector_model", "cycle_truly_ended"),
    "quality": ("hybrid_curve_quality_model", "problem_cycle"),
    "live_match": ("live_match_commit_model", "match_top1_correct"),
}

# The FIXED probability cutoff each live consumer applies to this capability's
# score. AUC alone is calibration-blind, so on-device retraining must also not
# degrade balanced accuracy AT the operating point the model is actually used at
# (else a "better AUC" model can silently shift decision rates). See _train_capability.
_OPERATING_THRESHOLD = {
    "end": DEFAULT_DEFER_FINISH_CONFIDENCE,
    "quality": ML_QUALITY_SUSPICIOUS_THRESHOLD,
    "live_match": ML_MATCH_COMMIT_THRESHOLD,
}

# Regression capabilities have no embedded baseline module - they are promoted
# only when they beat a naive analytic estimate on held-out data. capability ->
# (target label, target units).
_REGRESSION_CAPABILITIES = {
    "remaining_time": ("progress_fraction", "fraction"),
    "total_energy": ("energy_fraction", "fraction"),
}

# Elapsed fractions at which each clean cycle is cut to synthesize a training row.
_PROGRESS_CUT_FRACTIONS = (0.15, 0.30, 0.45, 0.60, 0.75, 0.90)

_ACTIVE_FLOOR_RATIO = 0.02
_MIN_ROWS = 40


def _read_points(cycle: dict[str, Any]) -> list[tuple[float, float]]:
    """Return power data as offset-seconds/watts pairs, handling str and datetime start_time."""
    from ..profile_store import decompress_power_data  # noqa: PLC0415
    try:
        return decompress_power_data(cycle)
    except Exception:  # noqa: BLE001
        return []


def _matrix(rows: list[dict[str, float]], columns: list[str]) -> np.ndarray:
    if not rows:
        return np.empty((0, len(columns)), dtype=float)
    return np.array(
        [[float(r.get(col) or 0.0) for col in columns] for r in rows], dtype=float
    )


def _end_dataset(
    clean: list[dict[str, Any]],
    expectations: dict[str, dict[str, float]],
    stop_thr: float,
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
    """Positives = each completed clean cycle's final end; negatives = pauses that resumed.

    Also returns a per-row ``groups`` array (source-cycle index) so the holdout split
    keeps every row from a given cycle on the same side (this dataset emits 1+N rows
    per cycle; row-level splitting would leak siblings across train/test — B5)."""
    from .feature_extraction import END_FEATURE_COLUMNS, latest_end_event_features

    rows: list[dict[str, float]] = []
    labels: list[float] = []
    groups: list[int] = []
    for ci, c in enumerate(clean):
        exp = expectations.get(c.get("profile_name"))
        if not exp:
            continue
        points = _read_points(c)
        if len(points) < 6:
            continue
        peak = max((p for _, p in points), default=0.0)
        if peak <= 0:
            continue
        active_thr = max(stop_thr, _ACTIVE_FLOOR_RATIO * peak)
        in_low = False
        low_start = 0.0
        for i, (t, p) in enumerate(points):
            if not in_low and p < active_thr:
                in_low = True
                low_start = t
            elif in_low and p >= active_thr:
                if (points[i - 1][0] - low_start) >= 30.0:
                    feat = latest_end_event_features(points[:i], exp)
                    if feat is not None:
                        rows.append(feat)
                        labels.append(0.0)  # resumed -> not the end
                        groups.append(ci)
                in_low = False
        feat_end = latest_end_event_features(points, exp)
        if feat_end is not None:
            rows.append(feat_end)
            labels.append(1.0)  # trace ends here -> true end
            groups.append(ci)
    return (_matrix(rows, list(END_FEATURE_COLUMNS)), np.array(labels, dtype=float),
            list(END_FEATURE_COLUMNS), np.array(groups, dtype=int))


def _quality_label(cycle: dict[str, Any]) -> float | None:
    """1 = problem, 0 = clean, None = unknown (skip)."""
    review = cycle.get("ml_review")
    if isinstance(review, dict):
        if review.get("golden"):
            return 0.0  # pinned reference cycle -> definitely clean
        q = review.get("quality")
        if q in ("good", "golden"):
            return 0.0
        if q in ("bad", "unusable"):
            return 1.0
    status = cycle.get("status")
    if status in ("force_stopped", "interrupted"):
        return 1.0
    if status == "completed":
        return 0.0
    return None


def _quality_dataset(
    cycles: list[dict[str, Any]],
    expectations: dict[str, dict[str, float]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
    """Uses ALL cycles (not clean-filtered) so mis-detected cycles are the positives.

    Emits at most one row per cycle, so ``groups`` is unique-per-row (splitting by
    group is equivalent to row-level here) — returned for a uniform split API."""
    from .feature_extraction import QUALITY_FEATURE_COLUMNS, quality_features

    rows: list[dict[str, float]] = []
    labels: list[float] = []
    groups: list[int] = []
    for ci, c in enumerate(cycles):
        exp = expectations.get(c.get("profile_name"))
        if not exp:
            continue
        label = _quality_label(c)
        if label is None:
            continue
        points = _read_points(c)
        if len(points) < 6:
            continue
        raw_conf = c.get("match_confidence")
        if isinstance(raw_conf, (int, float)) and not isinstance(raw_conf, bool) and raw_conf > 0:
            conf = float(raw_conf)
            proxy_dist, proxy_margin, proxy_fit = max(0.0, 1.0 - conf), conf, conf
        else:
            proxy_dist, proxy_margin, proxy_fit = 0.25, 0.30, 0.75
        # Use the cycle's real detected-artifact count so the flag_pressure feature
        # is not train-time-constant (which would zero its learned coefficient and
        # blind the AUC gate to it). Mirrors inference in manager._compute_cycle_quality_score.
        arts = c.get("artifacts")
        flag_count = len(arts) if isinstance(arts, list) else 0
        try:
            feat = quality_features(
                points, exp["duration"], exp["energy"], exp["peak"],
                proxy_dist, proxy_margin, proxy_fit, flag_count,
            )
        except Exception:  # pylint: disable=broad-exception-caught
            continue
        rows.append(feat)
        labels.append(label)
        groups.append(ci)
    return (_matrix(rows, list(QUALITY_FEATURE_COLUMNS)), np.array(labels, dtype=float),
            list(QUALITY_FEATURE_COLUMNS), np.array(groups, dtype=int))


def _live_match_dataset(
    snapshots: list[dict[str, Any]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
    """Build a training matrix from accumulated match ranking snapshots.

    Each snapshot was captured mid-cycle; at cycle end the confirmed profile
    was back-filled as ``confirmed_label``.  We label by whether the model's
    top-1 candidate at recording time matched the final confirmed label:
    1.0 = top-1 was correct (should commit), 0.0 = wrong (should not commit).
    Snapshots without a confirmed label are skipped.

    One cycle produces several snapshots (matching re-runs every ~5 min), all
    back-filled with the same label, so ``groups`` keys rows by source cycle
    (``cycle_id`` → ``start_time_iso`` → unique) to stop the holdout split leaking
    correlated same-cycle snapshots across train/test (B5).
    """
    from .feature_extraction import LIVE_MATCH_FEATURE_COLUMNS

    columns = list(LIVE_MATCH_FEATURE_COLUMNS)
    rows: list[dict[str, float]] = []
    labels: list[float] = []
    group_keys: list[str] = []
    for i, snap in enumerate(snapshots):
        if not isinstance(snap, dict):
            continue
        confirmed = snap.get("confirmed_label")
        if not isinstance(confirmed, str) or not confirmed:
            continue
        top1 = snap.get("top1_profile")
        if not isinstance(top1, str):
            continue
        feat = snap.get("features")
        if not isinstance(feat, dict):
            continue
        label = 1.0 if confirmed == top1 else 0.0
        rows.append({col: float(feat.get(col) or 0.0) for col in columns})
        labels.append(label)
        group_keys.append(str(snap.get("cycle_id") or snap.get("start_time_iso") or f"_row{i}"))
    return (_matrix(rows, columns), np.array(labels, dtype=float),
            columns, _group_ids(group_keys))


def _group_ids(keys: list[Any]) -> np.ndarray:
    """Map an ordered list of group keys to stable integer ids (first-seen order)."""
    seen: dict[Any, int] = {}
    out: list[int] = []
    for k in keys:
        if k not in seen:
            seen[k] = len(seen)
        out.append(seen[k])
    return np.array(out, dtype=int)


def _progress_dataset(
    clean: list[dict[str, Any]],
    expectations: dict[str, dict[str, float]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
    """Synthesize (features, completion_fraction) rows for the remaining-time model.

    Each clean completed cycle is cut at several elapsed fractions; the target is
    the true completion fraction of the prefix (``prefix_elapsed / total``). This
    turns every stored trace into a handful of supervised progress examples, so
    the regressor learns the device's own progress curve (e.g. a program that
    reliably runs longer than its labelled duration) rather than the naive
    elapsed/expected assumption.
    """
    from .feature_extraction import PROGRESS_FEATURE_COLUMNS, progress_features

    columns = list(PROGRESS_FEATURE_COLUMNS)
    rows: list[dict[str, float]] = []
    labels: list[float] = []
    groups: list[int] = []
    for ci, c in enumerate(clean):
        exp = expectations.get(c.get("profile_name"))
        if not exp:
            continue
        points = _read_points(c)
        if len(points) < 12:
            continue
        t0 = points[0][0]
        total = points[-1][0] - t0
        if total <= 60.0:
            continue
        for frac in _PROGRESS_CUT_FRACTIONS:
            cut_t = t0 + frac * total
            prefix = [(o, p) for o, p in points if o <= cut_t]
            if len(prefix) < 4:
                continue
            feat = progress_features(prefix, exp)
            if feat is None:
                continue
            actual_elapsed = prefix[-1][0] - t0
            label = actual_elapsed / total
            rows.append(feat)
            labels.append(float(min(max(label, 0.0), 1.0)))
            groups.append(ci)
    return (_matrix(rows, columns), np.array(labels, dtype=float),
            columns, np.array(groups, dtype=int))


def _energy_dataset(
    clean: list[dict[str, Any]],
    expectations: dict[str, dict[str, float]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
    """Synthesize (features, energy_completion_fraction) rows for the total-energy
    model. Same feature vector as the remaining-time model; the label is
    ``energy_so_far / total_energy`` at each cut, so the regressor learns how
    energy accumulates *non-linearly* over the cycle (heating front-loads it)
    rather than assuming it tracks elapsed time. The naive baseline in
    ``_train_regression_capability`` is ``elapsed_over_expected`` (time progress),
    which is exactly the current ``energy_so_far / progress`` projection — so a
    model is only promoted when it beats that.
    """
    from .feature_extraction import (
        PROGRESS_FEATURE_COLUMNS,
        progress_features,
        cumulative_energy_wh,
    )

    columns = list(PROGRESS_FEATURE_COLUMNS)
    rows: list[dict[str, float]] = []
    labels: list[float] = []
    groups: list[int] = []
    for ci, c in enumerate(clean):
        exp = expectations.get(c.get("profile_name"))
        if not exp:
            continue
        points = _read_points(c)
        if len(points) < 12:
            continue
        t0 = points[0][0]
        total_dur = points[-1][0] - t0
        if total_dur <= 60.0:
            continue
        total_energy = float(cumulative_energy_wh(points)[-1])
        if total_energy <= 1e-6:
            continue
        for frac in _PROGRESS_CUT_FRACTIONS:
            cut_t = t0 + frac * total_dur
            prefix = [(o, p) for o, p in points if o <= cut_t]
            if len(prefix) < 4:
                continue
            feat = progress_features(prefix, exp)
            if feat is None:
                continue
            energy_so_far = float(cumulative_energy_wh(prefix)[-1])
            label = energy_so_far / total_energy
            rows.append(feat)
            labels.append(float(min(max(label, 0.0), 1.0)))
            groups.append(ci)
    return (_matrix(rows, columns), np.array(labels, dtype=float),
            columns, np.array(groups, dtype=int))


def _group_holdout_indices(
    groups: np.ndarray, frac: float, seed: int
) -> tuple[np.ndarray, np.ndarray] | None:
    """Assign whole groups to train/test so no group straddles the split (B5).

    Returns (train_idx, test_idx) row-index arrays, or None if there are too few
    distinct groups to hold any out while leaving ≥1 training group.
    """
    uniq = np.unique(groups)
    if uniq.size < 2:
        return None
    rng = np.random.default_rng(seed)
    perm = rng.permutation(uniq)
    n_test_groups = max(1, int(round(uniq.size * frac)))
    if uniq.size - n_test_groups < 1:
        n_test_groups = uniq.size - 1
    test_groups = set(perm[:n_test_groups].tolist())
    test_mask = np.array([g in test_groups for g in groups])
    train_idx = np.where(~test_mask)[0]
    test_idx = np.where(test_mask)[0]
    if train_idx.size == 0 or test_idx.size == 0:
        return None
    return train_idx, test_idx


def _regression_split(
    X: np.ndarray, y: np.ndarray, groups: np.ndarray | None = None,
    *, frac: float = 0.2, seed: int = 0
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Seeded train/test split for regression (no class balancing).

    When ``groups`` is given, splits by group so correlated same-cycle rows never
    span train and test; falls back to in-sample eval if it cannot.
    """
    n = X.shape[0]
    if groups is not None and getattr(groups, "size", 0) == n:
        split = _group_holdout_indices(groups, frac, seed)
        if split is not None and split[0].size >= 2:
            train_idx, test_idx = split
            return X[train_idx], y[train_idx], X[test_idx], y[test_idx]
        return X, y, X, y
    rng = np.random.default_rng(seed)
    idx = rng.permutation(n)
    n_test = max(1, int(round(n * frac)))
    if n - n_test < 2:  # keep at least a couple of training rows
        return X, y, X, y
    test_idx, train_idx = idx[:n_test], idx[n_test:]
    return X[train_idx], y[train_idx], X[test_idx], y[test_idx]


def _train_regression_capability(
    capability: str,
    target: str,
    target_units: str,
    X: np.ndarray,
    y: np.ndarray,
    columns: list[str],
    trained_at: str,
    groups: np.ndarray | None = None,
) -> dict[str, Any]:
    """Fit + gate one regression capability against a naive analytic baseline.

    The naive baseline for the completion-fraction target is
    ``elapsed_over_expected`` (the first feature column) clamped to [0, 1] - i.e.
    the current profile-duration assumption. A trained regressor is only promoted
    when its held-out MAE is at least ``ML_TRAINING_REGRESSION_MARGIN`` lower.
    """
    n = X.shape[0]
    if n < ML_TRAINING_MIN_REGRESSION_ROWS:
        return {"capability": capability, "promoted": False,
                "reason": f"insufficient data (rows={n})"}

    X_tr, y_tr, X_te, y_te = _regression_split(X, y, groups)
    # Detect in-sample fallback (too few rows to split).
    in_sample = X_tr is X and X_te is X
    if in_sample:
        _LOGGER.warning(
            "ML training '%s': too few rows (%d) to split for regression — "
            "evaluating in-sample; NOT promoting. Add more cycles for a reliable holdout.",
            capability, n,
        )
    try:
        fit = T.fit_ridge(X_tr, y_tr, alpha=1.0)
    except ValueError as err:
        return {"capability": capability, "promoted": False, "reason": str(err)}

    spec_probe = {
        "center": fit["center"], "scale": fit["scale"], "coef": fit["coef"],
        "bias": fit["bias"], "output_center": fit["y_center"], "output_scale": fit["y_scale"],
        "feature_columns": columns,
    }
    preds = np.clip(T.predict_matrix_spec(spec_probe, X_te), 0.0, 1.0)
    metrics = T.regression_metrics(y_te, preds)
    # Explicit None check — `or 1.0` would coerce MAE=0.0 to 1.0, rejecting a
    # perfect regressor and blocking promotion.
    model_mae = float(metrics.get("mae") if metrics.get("mae") is not None else 1.0)

    naive_col = columns.index("elapsed_over_expected") if "elapsed_over_expected" in columns else 0
    naive = np.clip(X_te[:, naive_col], 0.0, 1.0)
    naive_mae = float(np.mean(np.abs(naive - y_te))) if y_te.size else 1.0

    # Distinct source cycles: each clean cycle contributes several prefix rows via
    # `groups`, so ``n`` (rows) overstates how many real cycles trained the model.
    n_cycles = (
        int(np.unique(groups).size)
        if groups is not None and getattr(groups, "size", 0) == n
        else n
    )
    # Never promote on an in-sample (non-held-out) evaluation.
    promote = (model_mae <= naive_mae * (1.0 - ML_TRAINING_REGRESSION_MARGIN)) and not in_sample
    record: dict[str, Any] = {
        "capability": capability,
        "promoted": bool(promote),
        "rows": n,
        "cycle_count": n_cycles,
        "model_mae": round(model_mae, 5),
        "naive_mae": round(naive_mae, 5),
        "metrics": metrics,
    }
    if promote:
        record["spec"] = T.build_regression_spec(
            name=capability, target=target, feature_columns=columns, fit=fit,
            target_units=target_units,
            metrics={"holdout": metrics, "model_mae": round(model_mae, 5),
                     "naive_mae": round(naive_mae, 5)},
            trained_at=trained_at, cycle_count=n_cycles,
        )
        record["trained_at"] = trained_at
    elif in_sample:
        record["reason"] = "no held-out split (in-sample eval); not promoted"
    else:
        record["reason"] = f"MAE {model_mae:.4f} not below naive {naive_mae:.4f} - margin"
    return record


def _holdout_split(
    X: np.ndarray, y: np.ndarray, groups: np.ndarray | None = None,
    *, frac: float = 0.2, seed: int = 0
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Seeded split that keeps both classes in the test set when possible.

    When ``groups`` is given, splits by group (no same-cycle row spans the split, B5);
    if the resulting split loses a class from either side it retries a few seeds, then
    falls back to in-sample eval.
    """
    n = X.shape[0]
    if groups is not None and getattr(groups, "size", 0) == n:
        for s in range(seed, seed + 8):
            split = _group_holdout_indices(groups, frac, s)
            if split is None:
                break
            train_idx, test_idx = split
            if (len(np.unique(y[test_idx])) >= 2 and len(np.unique(y[train_idx])) >= 2):
                return X[train_idx], y[train_idx], X[test_idx], y[test_idx]
        return X, y, X, y
    rng = np.random.default_rng(seed)
    idx = rng.permutation(n)
    n_test = max(1, int(round(n * frac)))
    test_idx, train_idx = idx[:n_test], idx[n_test:]
    # Guarantee both classes present in test; otherwise fall back to all-data eval.
    if len(np.unique(y[test_idx])) < 2 or len(np.unique(y[train_idx])) < 2:
        return X, y, X, y
    return X[train_idx], y[train_idx], X[test_idx], y[test_idx]


def _embedded_module(capability: str):
    """Embedded baseline module for a capability, via the shared engine cache."""
    module_name = _CAPABILITIES.get(capability, (None, None))[0]
    if module_name is None:
        return None
    try:
        from .engine import _load_model_module

        return _load_model_module(module_name)
    except Exception:  # pylint: disable=broad-exception-caught
        return None


def _baseline_scores(capability: str, X_test: np.ndarray, columns: list[str]) -> np.ndarray | None:
    """Embedded-baseline probabilities on X_test, or None if it can't load/score."""
    module = _embedded_module(capability)
    if module is None:
        return None
    try:
        return np.array(
            [float(module.score(dict(zip(columns, row)))) for row in X_test], dtype=float
        )
    except Exception:  # pylint: disable=broad-exception-caught
        return None


def _baseline_threshold(capability: str, default: float) -> float:
    module = _embedded_module(capability)
    thr = getattr(module, "THRESHOLD", None) if module is not None else None
    return float(thr) if isinstance(thr, (int, float)) else default


def _train_capability(
    capability: str,
    target: str,
    X: np.ndarray,
    y: np.ndarray,
    columns: list[str],
    trained_at: str,
    groups: np.ndarray | None = None,
) -> dict[str, Any]:
    """Fit + gate one capability. Returns a status record (promoted or not)."""
    n = X.shape[0]
    n_pos = int(np.sum(y == 1))
    n_neg = int(np.sum(y == 0))
    if n < _MIN_ROWS or n_pos < ML_TRAINING_MIN_POSITIVES or n_neg < 5:
        return {"capability": capability, "promoted": False,
                "reason": f"insufficient data (rows={n}, pos={n_pos}, neg={n_neg})"}

    X_tr, y_tr, X_te, y_te = _holdout_split(X, y, groups)
    # Detect in-sample fallback (holdout returned full dataset for both splits).
    in_sample = X_tr is X and X_te is X
    if in_sample:
        _LOGGER.warning(
            "ML training '%s': dataset too small or imbalanced to split "
            "(n=%d, pos=%d, neg=%d) — AUC evaluated in-sample; NOT promoting "
            "(an in-sample AUC is optimistic). Add more labeled cycles.",
            capability, n, n_pos, n_neg,
        )
    fit = T.fit_logistic(X_tr, y_tr)
    default_thr = _baseline_threshold(capability, 0.5)
    spec_probe = {"center": fit["center"], "scale": fit["scale"], "coef": fit["coef"],
                  "bias": fit["bias"], "feature_columns": columns}
    train_scores = T.score_matrix_spec(spec_probe, X_tr)
    threshold = T.select_threshold(y_tr, train_scores, default=default_thr)

    test_scores = T.score_matrix_spec(spec_probe, X_te)
    new_auc = T.auc(y_te, test_scores)
    metrics = T.binary_metrics(y_te, test_scores, threshold)
    # Distinct source cycles (some capabilities emit >1 row per cycle, e.g. an
    # end classifier with several candidate events); mirror the regression path.
    n_cycles = (
        int(np.unique(groups).size)
        if groups is not None and getattr(groups, "size", 0) == n
        else n
    )
    base_scores = _baseline_scores(capability, X_te, columns)
    if base_scores is None:
        # Every classifier capability ships an embedded baseline; None here means
        # it failed to load/score, NOT that it is legitimately absent. Don't promote
        # against a fabricated 0.5 bar (that would let a near-random model win).
        return {"capability": capability, "promoted": False,
                "rows": n, "positives": n_pos, "negatives": n_neg,
                "cycle_count": n_cycles, "new_auc": round(new_auc, 4),
                "threshold": threshold, "metrics": metrics,
                "reason": "embedded baseline unavailable; cannot gate promotion"}
    baseline = T.auc(y_te, base_scores)

    # Calibration-aware gate: the live consumer applies a FIXED probability cutoff to
    # this capability, so AUC (rank quality) alone isn't enough — a retrained model
    # must also not degrade balanced accuracy AT that operating cutoff, else a
    # differently-calibrated on-device model silently shifts decision rates.
    op_thr = _OPERATING_THRESHOLD.get(capability)
    trained_op_bacc: float | None = None
    base_op_bacc: float | None = None
    calib_ok = True
    if op_thr is not None:
        trained_op_bacc = float(
            T.binary_metrics(y_te, test_scores, op_thr).get("balanced_accuracy") or 0.0
        )
        base_op_bacc = float(
            T.binary_metrics(y_te, base_scores, op_thr).get("balanced_accuracy") or 0.0
        )
        calib_ok = trained_op_bacc >= (base_op_bacc - ML_TRAINING_BACC_MARGIN)

    # Never promote on an in-sample (non-held-out) evaluation: the AUC is optimistic.
    promote = (
        (new_auc >= (baseline - ML_TRAINING_AUC_MARGIN)) and not in_sample and calib_ok
    )
    record: dict[str, Any] = {
        "capability": capability,
        "promoted": bool(promote),
        "rows": n, "positives": n_pos, "negatives": n_neg,
        "cycle_count": n_cycles,
        "new_auc": round(new_auc, 4),
        "baseline_auc": round(baseline, 4),
        "threshold": threshold,
        "metrics": metrics,
    }
    if op_thr is not None:
        record["operating_threshold"] = op_thr
        record["op_balanced_accuracy"] = round(trained_op_bacc or 0.0, 4)
        record["baseline_op_balanced_accuracy"] = round(base_op_bacc or 0.0, 4)
    if promote:
        record["spec"] = T.build_spec(
            name=capability, target=target, feature_columns=columns,
            fit=fit, threshold=threshold,
            metrics={"holdout": metrics, "auc": round(new_auc, 4), "baseline_auc": round(baseline, 4)},
            trained_at=trained_at, cycle_count=n_cycles,
        )
        record["trained_at"] = trained_at
    elif in_sample:
        record["reason"] = "no held-out split (in-sample eval); not promoted"
    elif not calib_ok:
        record["reason"] = (
            f"balanced accuracy at operating threshold {op_thr} "
            f"({trained_op_bacc:.3f}) below baseline ({base_op_bacc:.3f}) - margin"
        )
    else:
        record["reason"] = f"AUC {new_auc:.3f} below baseline {baseline:.3f} - margin"
    return record


def train_from_cycles(
    cycles: list[dict[str, Any]],
    device_type: str | None,
    stop_threshold_w: float = 2.0,
    trained_at: str = "",
    ranking_history: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
    """Pure function (executor-safe): build datasets, train, gate all capabilities.

    Returns ``{"results": [record, ...], "promoted": {capability: record}}``.
    Caller persists the promoted records via ``profile_store.set_ml_model_version``.

    ``ranking_history`` is the accumulated match ranking snapshots from the store
    (see :meth:`.ProfileStore.get_match_ranking_history`).  When provided it
    unlocks on-device training for the ``live_match`` capability.
    """
    from ..suggestion_engine import select_clean_cycles
    from .feature_extraction import profile_expectations

    clean, _excluded = select_clean_cycles(cycles, stop_threshold_w=stop_threshold_w)
    expectations = profile_expectations(cycles)

    datasets: dict[str, tuple[np.ndarray, np.ndarray, list[str], np.ndarray]] = {
        "end": _end_dataset(clean, expectations, stop_threshold_w),
        "quality": _quality_dataset(cycles, expectations),
        "live_match": _live_match_dataset(ranking_history or []),
    }

    results: list[dict[str, Any]] = []
    promoted: dict[str, Any] = {}
    for capability, (module_name, target) in _CAPABILITIES.items():
        X, y, columns, groups = datasets[capability]
        try:
            record = _train_capability(capability, target, X, y, columns, trained_at, groups)
        except ValueError as exc:
            _LOGGER.debug("Skipping %s training: %s", capability, exc)
            results.append({"capability": capability, "promoted": False, "reason": str(exc)})
            continue
        results.append(record)
        if record.get("promoted") and "spec" in record:
            promoted[capability] = {
                "spec": record["spec"],
                "trained_at": trained_at,
                "cycle_count": record["cycle_count"],
                "metrics": record["metrics"],
                "new_auc": record["new_auc"],
                "baseline_auc": record["baseline_auc"],
            }

    # Regression capabilities (no embedded baseline; gated against a naive estimate).
    reg_datasets: dict[str, tuple[np.ndarray, np.ndarray, list[str], np.ndarray]] = {
        "remaining_time": _progress_dataset(clean, expectations),
        "total_energy": _energy_dataset(clean, expectations),
    }
    for capability, (target, target_units) in _REGRESSION_CAPABILITIES.items():
        X, y, columns, groups = reg_datasets[capability]
        record = _train_regression_capability(
            capability, target, target_units, X, y, columns, trained_at, groups
        )
        results.append(record)
        if record.get("promoted") and "spec" in record:
            promoted[capability] = {
                "spec": record["spec"],
                "trained_at": trained_at,
                "cycle_count": record["cycle_count"],
                "metrics": record["metrics"],
                "model_mae": record["model_mae"],
                "naive_mae": record["naive_mae"],
            }
    return {"results": results, "promoted": promoted}


async def async_run_training(hass: Any, manager: Any) -> dict[str, Any]:
    """Public entry point: train on this device's cycles and persist winners.

    Offloads the CPU work to an executor thread and persists any promoted model
    specs into the profile store. Returns a summary for logging / the event.
    """
    from ..const import CONF_MIN_POWER, CONF_STOP_THRESHOLD_W

    store = manager.profile_store
    entry = hass.config_entries.async_get_entry(manager.entry_id)
    merged = {**(entry.data if entry else {}), **(entry.options if entry else {})}
    stop_thr = 2.0
    for key in (CONF_STOP_THRESHOLD_W, CONF_MIN_POWER):
        try:
            v = float(merged.get(key))
        except (TypeError, ValueError):
            continue
        if v > 0:
            stop_thr = v
            break

    from homeassistant.util import dt as dt_util

    trained_at = dt_util.now().isoformat()
    cycles = list(store.get_past_cycles())  # snapshot before executor to avoid data race
    # get_match_ranking_history() already returns a shallow copy of the top-level
    # list, but wrap it in list(...) too so the executor never iterates a list that
    # the event loop could mutate mid-training - matching the get_past_cycles()
    # snapshot above.
    ranking_history = list(store.get_match_ranking_history())

    _LOGGER.info(
        "On-device ML training starting: %d cycles, %d ranking snapshots, "
        "device_type=%s, stop_threshold=%.1fW",
        len(cycles), len(ranking_history), manager.device_type, stop_thr,
    )
    summary = await hass.async_add_executor_job(
        train_from_cycles, cycles, manager.device_type, stop_thr, trained_at, ranking_history
    )
    for record in summary.get("results", []):
        is_regression = "model_mae" in record
        if record.get("promoted") and is_regression:
            _LOGGER.info(
                "ML training PROMOTED %s: MAE %.4f vs naive %.4f (rows=%s)",
                record["capability"], record.get("model_mae", 0), record.get("naive_mae", 0),
                record.get("rows"),
            )
        elif record.get("promoted"):
            _LOGGER.info(
                "ML training PROMOTED %s: AUC %.3f vs baseline %.3f (rows=%s, pos=%s)",
                record["capability"], record.get("new_auc", 0), record.get("baseline_auc", 0),
                record.get("rows"), record.get("positives"),
            )
        else:
            _LOGGER.info(
                "ML training kept baseline for %s: %s",
                record["capability"], record.get("reason", "not promoted"),
            )
    for capability, record in summary.get("promoted", {}).items():
        await store.set_ml_model_version(capability, record)
    return summary
