"""EvolutionService — the composition root wired from main.py.

Responsibilities:
  - Load ``_meta/evolution/config/evolution.yaml`` on startup (hot-reload
    on mtime change, like other skill configs)
  - Own the ``EvolutionLedger`` instance
  - Expose the three persist surfaces that ``Agent.invoke_skill`` calls
    inside its prolog + epilog (``persist_run``, ``persist_selector_telemetry``,
    ``persist_outcome_for_run``)
  - Derive OutcomeVector components from joined ``decision_log`` + cost
    rows and persist them through the ledger
  - Expose the read API surface (``get_status``, ``list_runs``, ``get_run``)
    used by ``/api/evolution/*`` routes
  - Refresh ``system/evolution/status.md`` on a cadence

Design notes:
  - NEVER raises in the persist path — a broken evolution layer must
    not break the agent loop. Every persist method is wrapped in
    try/except at the boundary.
  - Uses ``trust_reason='evolution_persist'`` for VaultWriter calls
    so the runtime guard passes without forcing every enclosing scope
    to authorize a specific contract_action.
"""

from __future__ import annotations

import logging
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

from agntspace.evolution.ledger import EvolutionLedger
from agntspace.evolution.outcomes import (
    build_outcome,
    default_weights,
    load_weights_from_config,
    resolve_weights_for_skill,
    reversibility_from_action,
)
from agntspace.evolution.schemas import (
    POLICY_INCUMBENT_DEFAULT,
    RunManifest,
    SelectorTelemetry,
)
from agntspace.evolution.storage import STATUS_PATH

if TYPE_CHECKING:
    import aiosqlite

log = logging.getLogger(__name__)


# ── Default config (used when the YAML isn't loadable) ──
_FALLBACK_CONFIG: dict[str, Any] = {  # arch:deterministic — fallback config
    "selection": {
        "kill_switch": False,
        "min_eligible_before_thompson": 3,
        "incumbent_prior_alpha": 1.0,
        "incumbent_prior_beta": 1.0,
    },
    "utility": {
        "weights": {
            "correctness":   0.30,
            "effect":        0.25,
            "safety":        0.20,
            "feedback":      0.10,
            "cost":          0.08,
            "latency":       0.05,
            "reversibility": 0.02,
        },
        "zero_effect_mask": 0.02,
        "input_floor": 0.01,
        "feedback_neutral_prior": 0.5,
        "cost_p50_window": 50,
        "latency_p50_window": 50,
    },
    "mining": {
        "enabled": True,
        "schedule_interval_hours": 24,
        "min_run_count_for_candidate": 5,
        "success_utility_threshold": 0.75,
        "failure_utility_threshold": 0.25,
    },
    "promotion": {
        "min_runs": 10,
        "min_replays": 3,
        "utility_uplift_min": 0.05,
        "confidence_min": 0.90,
        "safety_ema_floor": 0.95,
    },
    "drift": {
        "zero_effect_rate_threshold": 0.20,
        "utility_window": 20,
        "utility_drop_min": 0.08,
        "quarantine_after_days": 7,
    },
    "storage": {
        "vault_subdir": "system/evolution",
        "promotion_report_format": "markdown",
    },
}


class EvolutionService:
    """Composition root for the evolution learning loop.

    Wired from ``main.py`` after ``DecisionLogger.initialize()``:

        evolution = EvolutionService(
            db=db,
            skill_loader=skill_loader,
            decision_logger=decision_logger,
            vault_writer=vault_writer,
            cost_tracker=cost_tracker,
        )
        await evolution.initialize()
        app.state.evolution = evolution
        agent._evolution = evolution
        orchestrator._evolution = evolution
    """

    def __init__(
        self,
        *,
        db: aiosqlite.Connection | None = None,
        skill_loader: Any | None = None,
        decision_logger: Any | None = None,
        vault_writer: Any | None = None,
        cost_tracker: Any | None = None,
        vault_reader: Any | None = None,
        activity_logger: Any | None = None,
    ) -> None:
        self._db = db
        self._skill_loader = skill_loader
        self._decisions = decision_logger
        self._writer = vault_writer
        self._cost_tracker = cost_tracker
        self._vault_reader = vault_reader
        self._activity = activity_logger
        # Orchestrator is wired post-hoc from main.py because it's
        # constructed after EvolutionService — the replay engine needs it
        # but the mining/promotion/drift loops don't.
        self._orchestrator: Any | None = None

        self.ledger = EvolutionLedger(db=db)
        # Deep-copy: nested dicts ("selection", "utility", etc.) must NOT
        # be shared across instances. Without this, one test's
        # ``svc._config["selection"]["kill_switch"] = True`` would leak
        # into every subsequent EvolutionService initialized with the
        # fallback config — order-dependent test failures.
        from copy import deepcopy as _deep
        self._config: dict[str, Any] = _deep(_FALLBACK_CONFIG)
        self._config_path: Path | None = None
        self._config_mtime: float = 0.0
        self._weights: dict[str, float] = default_weights()

        # Rolling baselines for cost / latency p50. Cheap running
        # samples; reset on startup (acceptable for Phase 1).
        self._cost_samples: list[float] = []
        self._latency_samples: list[float] = []
        self._samples_window: int = 50

        self._initialized: bool = False

        # ── Meta-monitoring (subsystem health) ──
        # Counters that surface "is the evolution layer itself working?".
        # Without these, a silent failure in mining/promotion/drift could
        # hide for weeks. Pattern follows DecisionLogger.health().
        self._runs_recorded: int = 0
        self._runs_record_failures: int = 0
        self._last_run_at: str = ""
        self._last_mined_at: str = ""
        self._last_mining_summary: dict[str, Any] = {}
        self._last_promoted_at: str = ""
        self._last_promotion_summary: dict[str, Any] = {}
        self._last_drift_audit_at: str = ""
        self._last_drift_summary: dict[str, Any] = {}
        self._last_feedback_at: str = ""
        self._feedback_recorded: int = 0
        self._kernel_violations_caught: int = 0
        self._last_error: str = ""
        self._last_error_ts: str = ""

    # ── Lifecycle ──

    async def initialize(self) -> None:
        if self._initialized:
            return
        await self.ledger.initialize()
        self._load_config()
        self._initialized = True

    def _load_config(self) -> None:
        """Load ``_meta/evolution/config/evolution.yaml`` with mtime hot-reload.

        Importantly, the early-return paths (no skill loader, no config
        file) must NOT overwrite ``self._config`` — that would clobber
        any runtime mutation (e.g. ``set_kill_switch()`` flipping the
        in-memory mirror). The fallback config is already installed in
        ``__init__``; this method only refreshes it when the YAML on
        disk has actually changed.
        """
        from copy import deepcopy as _deep
        if not self._skill_loader:
            return
        skills_dir = getattr(self._skill_loader, "_skills_dir", None)
        if skills_dir is None:
            return

        config_path = Path(skills_dir) / "_meta" / "evolution" / "config" / "evolution.yaml"
        self._config_path = config_path
        if not config_path.is_file():
            return
        try:
            mtime = config_path.stat().st_mtime
        except OSError:
            return
        if mtime == self._config_mtime:
            return
        self._config_mtime = mtime
        try:
            import yaml
            with open(config_path, encoding="utf-8") as f:
                data = yaml.safe_load(f) or {}
            if not isinstance(data, dict):
                raise ValueError("evolution.yaml root is not a mapping")
            # Deep-copy fallback before merging so nested dicts in
            # _FALLBACK_CONFIG are never aliased across instances.
            merged = _deep(_FALLBACK_CONFIG)
            for k, v in data.items():
                if isinstance(v, dict) and isinstance(merged.get(k), dict):
                    merged[k] = {**merged[k], **v}
                else:
                    merged[k] = v
            self._config = merged
            self._weights = load_weights_from_config(merged)
            try:
                self._samples_window = int(
                    merged.get("utility", {}).get("cost_p50_window", 50),
                )
            except Exception:
                self._samples_window = 50
            log.info("Loaded evolution config from %s", config_path)
        except Exception:
            log.warning("Failed to load evolution.yaml — using fallback config", exc_info=True)

    @property
    def config(self) -> dict[str, Any]:
        """Return the loaded config dict (with mtime hot-reload)."""
        self._load_config()
        return self._config

    # ── Persist surfaces (called from Agent.invoke_skill prolog + epilog) ──

    async def persist_run(self, manifest: RunManifest) -> None:
        """Delegate to the ledger. Never raises — a broken ledger must
        not break the agent loop."""
        self._runs_recorded += 1
        try:
            await self.ledger.persist_run(manifest)
            self._last_run_at = manifest.started_at or datetime.now(UTC).strftime(
                "%Y-%m-%dT%H:%M:%S.%fZ",
            )
        except Exception as exc:
            self._runs_record_failures += 1
            self._last_error = str(exc)[:200]
            self._last_error_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            log.debug("EvolutionService.persist_run failed", exc_info=True)

    async def persist_selector_telemetry(
        self,
        *,
        run_id: str,
        policy_id: str,
        eligible_ids: list[str],
        top_samples: list[dict[str, Any]],
        chosen_reason: str,
    ) -> None:
        """Persist the selector's decision for one run."""
        telemetry = SelectorTelemetry(
            run_id=run_id,
            policy_id=policy_id,
            eligible_ids=list(eligible_ids),
            top_samples=list(top_samples),
            chosen_reason=chosen_reason,
        )
        try:
            await self.ledger.save_telemetry(telemetry)
        except Exception:
            log.debug("persist_selector_telemetry failed", exc_info=True)

    async def persist_outcome_for_run(
        self,
        *,
        manifest: RunManifest,
        succeeded: bool,
        zero_effect: bool,
        blocked: bool,
        error: str | None,
        duration_ms: int,
        correlation_id: str,
    ) -> None:
        """Derive the OutcomeVector from joined decision_log + cost_log + counter data,
        then persist + mark the run complete.

        Reads:
          - ``decision_log`` rows for this correlation_id (tool outcomes, safety flags)
          - ``cost_log`` rows for this correlation_id (if cost_tracker wired)
          - The RunManifest (mutations come from the counter summary that
            invoke_skill stored in the outcome — but we re-query decisions
            to pick up result_status classifications)

        Never raises — a broken ledger must not break the agent loop.
        """
        try:
            tool_decisions = await self._query_decisions_for_correlation(correlation_id)
            mutations = _mutations_from_decisions(tool_decisions)

            # Reversibility proxy: derive from the contract actions seen
            # in this correlation scope. Higher-risk actions drive the
            # proxy down. Empty set → neutral prior (0.5).
            reversibility_risk = _reversibility_from_decisions(tool_decisions)

            cost = await self._cost_for_correlation(correlation_id)
            cost_p50 = self._cost_p50_and_record(cost)
            latency_p50 = self._latency_p50_and_record(float(duration_ms))

            # Per-skill weight resolution: different skills weight safety /
            # latency / correctness differently. Falls back to global
            # ``self._weights`` when no per-skill or family override exists.
            weights_for_run, _weight_source = resolve_weights_for_skill(
                manifest.skill_name, self._config,
            )
            outcome = build_outcome(
                run_id=manifest.run_id,
                mutations=mutations,
                tool_decisions=tool_decisions,
                cost=cost,
                duration_ms=duration_ms,
                feedback_score=None,   # Phase 1: deferred; pulled from activity in Phase 3+
                reversibility_risk=reversibility_risk,
                cost_p50=cost_p50,
                latency_p50_ms=latency_p50,
                zero_effect=zero_effect,
                blocked=blocked,
                error=error,
                rolled_back=False,
                weights=weights_for_run,
            )
            # Annotate the persisted components with the weight source so
            # retroactive recomputation (if weights change) can tell which
            # profile was used for each run.
            outcome.components["_weight_source"] = _weight_source
            await self.ledger.persist_outcome(outcome)
            await self.ledger.complete_run(
                manifest.run_id,
                completed_at=outcome.computed_at,
                duration_ms=duration_ms,
            )

            # Evolution v2 #1 integration: update the selected policy's
            # Beta-Bernoulli posterior on EVERY run completion. Prior
            # behaviour only updated posteriors on user feedback events,
            # so non-incumbent policies (active reflexes chosen by
            # Thompson sampling) had their evidence silently discarded
            # between feedback interactions. The posterior update is
            # cheap (one integer increment), mirrors the same threshold
            # the miner + drift detector use, and keeps the selector's
            # Thompson draws honest run-to-run.
            #
            # Skipped for the incumbent default — the incumbent policy is
            # a synthetic anchor, not a real reflex row. Same guard the
            # feedback path uses.
            policy_id = manifest.policy_id or POLICY_INCUMBENT_DEFAULT
            if policy_id not in (POLICY_INCUMBENT_DEFAULT, ""):
                try:
                    success_threshold = float(
                        self.config.get("mining", {})
                        .get("success_utility_threshold", 0.75),
                    )
                    await self.ledger.update_posterior(
                        policy_id, outcome.utility,
                        success_threshold=success_threshold,
                    )
                except Exception:
                    log.debug(
                        "posterior update after run outcome failed",
                        exc_info=True,
                    )
        except Exception:
            log.debug("persist_outcome_for_run failed", exc_info=True)

    # ── Read surfaces ──

    async def get_status(self) -> dict[str, Any]:
        """Aggregate status for /api/evolution/status."""
        counts = await self.ledger.counts()
        cfg = self.config
        return {
            "healthy": True,
            "phase": "1-2",
            "kill_switch": bool(cfg.get("selection", {}).get("kill_switch", False)),
            "counts": counts,
            "weights": dict(self._weights),
            "config_path": str(self._config_path) if self._config_path else "",
        }

    async def list_runs(
        self,
        *,
        skill_name: str | None = None,
        policy_id: str | None = None,
        caller: str | None = None,
        since: str | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> list[dict[str, Any]]:
        return await self.ledger.list_runs(
            skill_name=skill_name,
            policy_id=policy_id,
            caller=caller,
            since=since,
            limit=limit,
            offset=offset,
        )

    async def get_run(self, run_id: str) -> dict[str, Any] | None:
        return await self.ledger.get_run(run_id)

    async def list_reflexes(
        self, *, kind: str | None = None, status: str | None = None, limit: int = 100,
    ) -> list[dict[str, Any]]:
        """Return reflexes as plain dicts (Phase 2+ API surface)."""
        from dataclasses import asdict
        rs = await self.ledger.list_reflexes(kind=kind, status=status, limit=limit)
        return [asdict(r) for r in rs]

    async def list_promotions(self, *, limit: int = 50) -> list[dict[str, Any]]:
        from dataclasses import asdict
        ps = await self.ledger.list_promotions(limit=limit)
        return [asdict(p) for p in ps]

    # ── Vault status artifact ──

    async def refresh_status_artifact(self) -> None:
        """Rewrite ``system/evolution/status.md`` with current counts.

        Readable-by-any-editor artifact. Uses ``trust_reason='evolution_persist'``
        on the underlying VaultWriter call so the runtime guard passes.
        Silently no-ops when no writer is wired (tests, early boot).
        """
        if self._writer is None:
            return
        try:
            counts = await self.ledger.counts()
            cfg = self.config
            body = _render_status_markdown(counts, cfg, self._weights)
            try:
                await self._writer.write(
                    STATUS_PATH,
                    body,
                    frontmatter={
                        "title": "Evolution status",
                        "description": "Auto-generated snapshot of the evolution subsystem's state",
                        "date": time.strftime("%Y-%m-%d"),
                        "author": "agent",
                        "agent": "evolution",
                    },
                    trust_reason="evolution_persist",
                )
            except TypeError:
                # Older VaultWriter signature without frontmatter / trust_reason
                # — fall back to a plain body write.
                await self._writer.write(STATUS_PATH, body)
        except Exception:
            log.debug("refresh_status_artifact failed", exc_info=True)

    # ── Work queue handlers (Phase 2-5 active) ──

    async def handle_mine_reflexes(self, payload: dict | None = None) -> dict[str, Any]:
        """Phase 2: scan runs; propose Reflex / AntiReflex candidates."""
        if not self._initialized:
            return {"status": "deferred", "reason": "evolution_not_initialized"}
        try:
            from agntspace.evolution.reflex import ReflexMiner
            miner = ReflexMiner(
                ledger=self.ledger, config=self.config,
                writer=self._writer, activity_logger=self._activity,
            )
            result = await miner.mine()
            self._last_mined_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            self._last_mining_summary = {
                k: v for k, v in result.items()
                if k in ("status", "runs_scanned", "clusters",
                         "reflex_candidates", "anti_reflex_candidates",
                         "rejected_by_kernel")
            }
            self._kernel_violations_caught += int(result.get("rejected_by_kernel", 0) or 0)
            return result
        except Exception as exc:
            self._last_error = str(exc)[:200]
            self._last_error_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            log.exception("mine_reflexes failed")
            return {"status": "failed"}

    async def handle_replay(self, payload: dict | None = None) -> dict[str, Any]:
        """Phase 4: replay one candidate against a historical correlation_id.

        ``payload`` = ``{"candidate_id": str, "correlation_id_original": str}``.
        """
        if not self._initialized:
            return {"status": "deferred", "reason": "evolution_not_initialized"}
        if self._orchestrator is None:
            return {"status": "skipped", "reason": "no_orchestrator"}
        try:
            from agntspace.evolution.replay import ReplayEngine
            engine = ReplayEngine(
                ledger=self.ledger, service=self, orchestrator=self._orchestrator,
            )
            return await engine.replay_job(payload or {})
        except Exception:
            log.exception("replay failed")
            return {"status": "failed"}

    async def handle_promote(self, payload: dict | None = None) -> dict[str, Any]:
        """Phase 4: evaluate candidates → active transitions."""
        if not self._initialized:
            return {"status": "deferred", "reason": "evolution_not_initialized"}
        try:
            from agntspace.evolution.promotion import PromotionArena
            arena = PromotionArena(
                ledger=self.ledger, config=self.config,
                writer=self._writer, decision_logger=self._decisions,
                activity_logger=self._activity,
            )
            result = await arena.evaluate_all()
            self._last_promoted_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            self._last_promotion_summary = dict(result)
            return result
        except Exception as exc:
            self._last_error = str(exc)[:200]
            self._last_error_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            log.exception("promotion evaluation failed")
            return {"status": "failed"}

    async def handle_drift_scan(self, payload: dict | None = None) -> dict[str, Any]:
        """Phase 5: scan active reflexes for drift; quarantine + retire."""
        if not self._initialized:
            return {"status": "deferred", "reason": "evolution_not_initialized"}
        try:
            from agntspace.evolution.drift import DriftDetector
            detector = DriftDetector(
                ledger=self.ledger, config=self.config,
                writer=self._writer, activity_logger=self._activity,
            )
            result = await detector.audit()
            self._last_drift_audit_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            self._last_drift_summary = {
                k: v for k, v in result.items()
                if k in ("status", "active_scanned", "quarantined_scanned",
                         "quarantined_new", "retired", "report_path")
            }
            return result
        except Exception as exc:
            self._last_error = str(exc)[:200]
            self._last_error_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            log.exception("drift audit failed")
            return {"status": "failed"}

    # ── On-demand controls (called from /api/evolution/* POST routes) ──

    async def rollback_promotion(self, promotion_id: str) -> dict[str, Any]:
        """Revert a prior promotion. Called from POST /api/evolution/rollback/{id}."""
        if not self._initialized:
            return {"status": "not_ready"}
        try:
            from agntspace.evolution.promotion import PromotionArena
            arena = PromotionArena(
                ledger=self.ledger, config=self.config,
                writer=self._writer, decision_logger=self._decisions,
                activity_logger=self._activity,
            )
            return await arena.rollback(promotion_id)
        except Exception:
            log.exception("rollback failed")
            return {"status": "failed", "promotion_id": promotion_id}

    async def enqueue_replay_for_candidate(
        self, *, candidate_id: str, correlation_id_original: str,
    ) -> dict[str, Any]:
        """Create a pending replay job. Called from POST /api/evolution/replay/{id}."""
        if not self._initialized:
            return {"status": "not_ready"}
        try:
            job_id = await self.ledger.enqueue_replay(
                incumbent_id=POLICY_INCUMBENT_DEFAULT,
                candidate_id=candidate_id,
                correlation_id_original=correlation_id_original,
            )
            return {
                "status": "enqueued",
                "job_id": job_id,
                "candidate_id": candidate_id,
                "correlation_id_original": correlation_id_original,
            }
        except Exception:
            log.exception("enqueue_replay_for_candidate failed")
            return {"status": "failed"}

    async def set_kill_switch(self, enabled: bool) -> dict[str, Any]:
        """Toggle the selector kill switch in-memory.

        The YAML value is the persistent source of truth — this method
        flips the runtime mirror so a POST /api/evolution/kill-switch
        takes effect IMMEDIATELY (without waiting for the next mtime
        reload), but a server restart restores whatever the YAML says.

        Callers that want durable on/off should edit
        ``_meta/evolution/config/evolution.yaml`` directly.
        """
        sel = self._config.setdefault("selection", {})
        sel["kill_switch"] = bool(enabled)
        if self._activity is not None:
            try:
                self._activity.log(
                    "evolution",
                    "kill_switch_toggled",
                    f"Evolution selector kill switch {'ENABLED' if enabled else 'DISABLED'}",
                    {"enabled": bool(enabled)},
                    importance="high",
                )
            except Exception:
                log.debug("kill_switch activity log failed", exc_info=True)
        return {"status": "ok", "kill_switch": bool(enabled)}

    async def explain(
        self, skill_name: str, *,
        caller: str = "system",
        trigger_type: str = "",
        parent_run_id: str = "",
        rng_seed: int | None = None,
    ) -> dict[str, Any]:
        """Preview what the selector would do WITHOUT persisting a run.

        Thin wrapper around ``evolution.selector.explain_policy`` that
        passes the live ledger + config. Pure read — never touches
        selector_telemetry or evolution_runs.
        """
        if not self._initialized:
            return {
                "skill_name": skill_name,
                "caller": caller,
                "chosen": {
                    "policy_id": POLICY_INCUMBENT_DEFAULT,
                    "rationale": "fallback:evolution_not_initialized",
                    "sampled_score": 0.0,
                    "is_fallback": True,
                },
                "fallback_rule_triggered": "evolution_not_initialized",
                "eligible": [],
                "config": {"kill_switch": False, "min_eligible_before_thompson": 3,
                           "protected_skill": False, "protected_caller": False},
            }
        try:
            from agntspace.evolution.selector import explain_policy
            return await explain_policy(
                skill_name,
                caller=caller,
                trigger_type=trigger_type,
                parent_run_id=parent_run_id,
                ledger=self.ledger,
                config=self.config,
                rng_seed=rng_seed,
            )
        except Exception:
            log.exception("explain_policy failed")
            return {
                "skill_name": skill_name,
                "caller": caller,
                "chosen": {
                    "policy_id": POLICY_INCUMBENT_DEFAULT,
                    "rationale": "fallback:explain_failed",
                    "sampled_score": 0.0,
                    "is_fallback": True,
                },
                "fallback_rule_triggered": "explain_failed",
                "eligible": [],
                "config": {"kill_switch": False, "min_eligible_before_thompson": 3,
                           "protected_skill": False, "protected_caller": False},
            }

    async def record_feedback_by_correlation(
        self, correlation_id: str, feedback_score: float,
    ) -> dict[str, Any]:
        """Find the run(s) associated with ``correlation_id`` and attach feedback.

        Convenience wrapper around :meth:`record_feedback` for callers that
        know a correlation_id (approval endpoints reading a pending file's
        frontmatter) but don't want to query the ledger themselves.

        Graceful degradation: if no run matches the correlation_id, returns
        ``{"status": "skipped", "reason": "no_run_for_correlation"}`` —
        approval paths that originated outside the evolution layer (older
        content, Tier B flows not yet migrated) don't break the approval.

        When a correlation_id maps to multiple runs (nested invoke_skill),
        only the OUTERMOST run (the one without a ``parent_run_id``) gets
        the feedback — that's the run the user actually approved, nested
        calls are implementation detail.
        """
        if not self._initialized:
            return {"status": "not_ready"}
        if not correlation_id:
            return {"status": "skipped", "reason": "no_correlation_id"}
        try:
            # list_runs returns newest-first; the outermost run is the one
            # whose parent_run_id is empty. If there are multiple matches
            # (rare — should only happen if a correlation_id was reused),
            # we pick the outermost one.
            runs = await self.ledger.list_runs(limit=100)
            matching = [r for r in runs if r.get("correlation_id") == correlation_id]
            if not matching:
                return {
                    "status": "skipped",
                    "reason": "no_run_for_correlation",
                    "correlation_id": correlation_id,
                }
            outermost = next(
                (r for r in matching if not r.get("parent_run_id")),
                matching[0],
            )
            result = await self.record_feedback(outermost["run_id"], feedback_score)
            result["correlation_id"] = correlation_id
            return result
        except Exception:
            log.debug("record_feedback_by_correlation failed", exc_info=True)
            return {"status": "failed", "correlation_id": correlation_id}

    async def record_feedback(
        self, run_id: str, feedback_score: float,
    ) -> dict[str, Any]:
        """Manually attach a feedback score to a completed run's outcome.

        Called from approval endpoints (memory-approve / reflection-approve
        / relationship-approve) once a correlation_id is tracked. Score is
        clipped to [0, 1]. Re-persists the outcome with updated utility.

        This closes the feedback loop without requiring invoke_skill to
        wait for user approval — runs complete immediately, feedback
        lands asynchronously.
        """
        if not self._initialized:
            return {"status": "not_ready"}
        score = max(0.0, min(1.0, float(feedback_score)))
        run = await self.get_run(run_id)
        if not run:
            return {"status": "not_found", "run_id": run_id}
        outcome = run.get("outcome") or {}
        components = dict(outcome.get("components") or {})
        components["feedback"] = score
        components["_feedback_raw"] = score

        # Recompute utility with the new feedback value — use the SAME
        # weight profile that built the original outcome (persisted in
        # components._weight_source). When unknown (legacy outcomes from
        # before per-skill weights shipped), fall back to global weights
        # resolved against the run's skill_name.
        from agntspace.evolution.outcomes import compute_utility
        from agntspace.evolution.schemas import OutcomeVector
        _resolved_weights, _ = resolve_weights_for_skill(
            str(run.get("skill_name", "")), self._config,
        )
        new_utility = compute_utility(
            components,
            weights=_resolved_weights,
            zero_effect=bool(outcome.get("zero_effect", False)),
            blocked=bool(outcome.get("blocked", False)),
        )
        updated = OutcomeVector(
            run_id=run_id,
            utility=round(new_utility, 6),
            zero_effect=bool(outcome.get("zero_effect", False)),
            blocked=bool(outcome.get("blocked", False)),
            components=components,
        )
        await self.ledger.persist_outcome(updated)

        # Also bump the reflex posterior if this run was attributed to a non-incumbent policy
        policy_id = run.get("policy_id") or POLICY_INCUMBENT_DEFAULT
        if policy_id not in (POLICY_INCUMBENT_DEFAULT, ""):
            try:
                # Evolution v2 #1: pass the mining success threshold so the
                # posterior's Bernoulli classification uses the same cutoff
                # as the miner + drift detector. ONE notion of success,
                # sourced from evolution.yaml.
                success_threshold = float(
                    self.config.get("mining", {})
                    .get("success_utility_threshold", 0.75),
                )
                await self.ledger.update_posterior(
                    policy_id, new_utility,
                    success_threshold=success_threshold,
                )
            except Exception:
                log.debug("posterior re-update after feedback failed", exc_info=True)

        if self._activity is not None:
            try:
                self._activity.log(
                    "evolution",
                    "feedback_recorded",
                    f"Feedback {score:.2f} recorded for run {run_id[:16]}",
                    {
                        "run_id": run_id,
                        "policy_id": policy_id,
                        "feedback_score": score,
                        "new_utility": round(new_utility, 4),
                    },
                    importance="low",
                )
            except Exception:
                log.debug("feedback activity log failed", exc_info=True)

        # Meta-monitoring: track that feedback is actually flowing.
        self._feedback_recorded += 1
        self._last_feedback_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")

        return {
            "status": "ok",
            "run_id": run_id,
            "feedback_score": score,
            "new_utility": round(new_utility, 4),
        }

    def health(self) -> dict[str, Any]:
        """Subsystem self-monitoring (gap closure for the evolution layer
        being able to silently fail).

        Surfaces:
          - Whether the layer is initialized
          - Lifetime counters (runs recorded, failures, feedback events)
          - Recency markers (last_run_at, last_mined_at, last_promoted_at,
            last_drift_audit_at, last_feedback_at)
          - Last error + timestamp (set by any persist/handler exception)
          - Last summary from each handler (mining/promotion/drift) so a
            human reviewer can spot "promotion arena ran but produced 0
            promotes for 3 days straight" without querying the ledger.

        Returned synchronously so a health endpoint can read it without
        awaiting the DB. Mirrors ``DecisionLogger.health()`` shape.
        """
        attempts = self._runs_recorded
        failures = self._runs_record_failures
        failure_rate = failures / attempts if attempts > 0 else 0.0
        return {
            "initialized": self._initialized,
            "kill_switch": bool(self._config.get("selection", {}).get("kill_switch", False)),
            # Lifetime counters
            "runs_recorded": attempts,
            "runs_record_failures": failures,
            "runs_failure_rate": round(failure_rate, 4),
            "feedback_recorded": self._feedback_recorded,
            "kernel_violations_caught": self._kernel_violations_caught,
            # Recency markers (empty string when never run)
            "last_run_at": self._last_run_at,
            "last_mined_at": self._last_mined_at,
            "last_promoted_at": self._last_promoted_at,
            "last_drift_audit_at": self._last_drift_audit_at,
            "last_feedback_at": self._last_feedback_at,
            # Last summaries — small dicts so the admin UI can show
            # "last mining: 3 candidates from 120 runs" inline
            "last_mining_summary": dict(self._last_mining_summary),
            "last_promotion_summary": dict(self._last_promotion_summary),
            "last_drift_summary": dict(self._last_drift_summary),
            # Error tracking — non-empty means a handler raised recently
            "last_error": self._last_error,
            "last_error_ts": self._last_error_ts,
        }

    # ── Internals ──

    async def _query_decisions_for_correlation(self, correlation_id: str) -> list[dict[str, Any]]:
        if not correlation_id or self._decisions is None:
            return []
        try:
            return await self._decisions.query(
                correlation_id=correlation_id, limit=500,
            )
        except Exception:
            return []

    async def _cost_for_correlation(self, correlation_id: str) -> float:
        """Return total cost attributed to this correlation chain.

        We don't have a direct cost_by_correlation API on cost_tracker
        today; attribution flows through ``goal_id`` for the heartbeat's
        goal pursuit and through per-call tracking otherwise. Phase 1
        returns 0.0 when no cheap path exists; Phase 3+ will add a
        correlation-scoped cost tracker.
        """
        _ = correlation_id  # reserved for Phase 3+
        return 0.0

    def _cost_p50_and_record(self, cost: float) -> float:
        """Track a rolling window of costs and return the current p50.

        Simple list-based sample store (bounded by ``_samples_window``).
        Acceptable at Phase 1 scale — per-skill baselines come in Phase 3
        when the selector needs them.
        """
        if cost > 0:
            self._cost_samples.append(float(cost))
            if len(self._cost_samples) > self._samples_window:
                self._cost_samples = self._cost_samples[-self._samples_window:]
        if not self._cost_samples:
            return 0.0
        s = sorted(self._cost_samples)
        return s[len(s) // 2]

    def _latency_p50_and_record(self, duration_ms: float) -> float:
        if duration_ms > 0:
            self._latency_samples.append(float(duration_ms))
            if len(self._latency_samples) > self._samples_window:
                self._latency_samples = self._latency_samples[-self._samples_window:]
        if not self._latency_samples:
            return 0.0
        s = sorted(self._latency_samples)
        return s[len(s) // 2]


# ── Helpers ──


def _mutations_from_decisions(decisions: list[dict[str, Any]]) -> dict[str, int]:
    """Aggregate mutation counts from decision rows.

    We infer:
      vault_writes — every decision with action_type='vault_write' and result_status='ok'
      tool_calls   — every decision with action_type='tool' and result_status='ok'
      graph_triples — pulled from details.graph_triples if present, else inferred
                     from 'graph' / 'triple' action types
    """
    vault_writes = 0
    tool_calls = 0
    graph_triples = 0
    for d in decisions:
        status = (d.get("result_status") or "").lower()
        if status and status != "ok":
            continue
        at = (d.get("action_type") or "").lower()
        if at == "vault_write":
            vault_writes += 1
        elif at == "tool":
            tool_calls += 1
        elif "triple" in at or at == "graph":
            graph_triples += 1
    return {
        "vault_writes": vault_writes,
        "tool_calls": tool_calls,
        "graph_triples": graph_triples,
    }


def _reversibility_from_decisions(decisions: list[dict[str, Any]]) -> float | None:
    """Derive a reversibility risk in [0, 1] from the contract actions seen.

    Uses the max risk across contract actions — if ANY decision was a
    destructive action, the whole run has blast-radius risk equal to
    the worst one. Returns None when no decisions were recorded (neutral
    prior kicks in upstream).
    """
    if not decisions:
        return None
    max_risk = 0.0
    seen = False
    for d in decisions:
        action = d.get("contract_action") or ""
        if not action:
            continue
        seen = True
        risk = reversibility_from_action(action)
        if risk > max_risk:
            max_risk = risk
    return max_risk if seen else None


def _render_status_markdown(
    counts: dict[str, int], cfg: dict[str, Any], weights: dict[str, float],
) -> str:
    """Render the ``system/evolution/status.md`` body."""
    sel = cfg.get("selection", {})
    util = cfg.get("utility", {})
    mining = cfg.get("mining", {})
    promo = cfg.get("promotion", {})
    drift = cfg.get("drift", {})

    def row(k: str, v: Any) -> str:
        return f"| {k} | {v} |"

    lines = [
        "# Evolution subsystem — status",
        "",
        "This is an auto-generated snapshot — do not edit by hand. Tune the",
        "subsystem via `defaults/skills/_meta/evolution/config/evolution.yaml`.",
        "",
        "## Counts",
        "",
        "| Field | Count |",
        "| --- | --- |",
        row("Runs recorded", counts.get("runs", 0)),
        row("Outcomes computed", counts.get("outcomes", 0)),
        row("Reflexes (active)", counts.get("active", 0)),
        row("Reflexes (candidate)", counts.get("candidates", 0)),
        row("Reflexes (draft)", counts.get("drafts", 0)),
        row("Reflexes (quarantined)", counts.get("quarantined", 0)),
        row("Reflexes (retired)", counts.get("retired", 0)),
        row("Anti-reflexes", counts.get("anti_reflexes", 0)),
        row("Promotion decisions", counts.get("promotions", 0)),
        row("Replay jobs", counts.get("replays", 0)),
        "",
        "## Selection",
        "",
        "| Setting | Value |",
        "| --- | --- |",
        row("Kill switch", bool(sel.get("kill_switch", False))),
        row("Min eligible before Thompson", sel.get("min_eligible_before_thompson", 3)),
        "",
        "## Utility weights",
        "",
        "| Component | Weight |",
        "| --- | --- |",
        *[row(k, round(float(v), 3)) for k, v in weights.items()],
        row("(zero-effect mask)", util.get("zero_effect_mask", 0.02)),
        "",
        "## Mining (Phase 2)",
        "",
        "| Setting | Value |",
        "| --- | --- |",
        row("Enabled", bool(mining.get("enabled", True))),
        row("Schedule (hours)", mining.get("schedule_interval_hours", 24)),
        row("Min runs per candidate", mining.get("min_run_count_for_candidate", 5)),
        row("Success utility threshold", mining.get("success_utility_threshold", 0.75)),
        row("Failure utility threshold", mining.get("failure_utility_threshold", 0.25)),
        "",
        "## Promotion (Phase 4)",
        "",
        "| Setting | Value |",
        "| --- | --- |",
        row("Min runs", promo.get("min_runs", 10)),
        row("Min replays", promo.get("min_replays", 3)),
        row("Utility uplift min", promo.get("utility_uplift_min", 0.05)),
        row("Confidence min", promo.get("confidence_min", 0.90)),
        row("Safety EMA floor", promo.get("safety_ema_floor", 0.95)),
        "",
        "## Drift (Phase 5)",
        "",
        "| Setting | Value |",
        "| --- | --- |",
        row("Zero-effect rate threshold", drift.get("zero_effect_rate_threshold", 0.20)),
        row("Utility window", drift.get("utility_window", 20)),
        row("Utility drop min", drift.get("utility_drop_min", 0.08)),
        row("Quarantine after (days)", drift.get("quarantine_after_days", 7)),
        "",
        "## How to disable",
        "",
        "Set `selection.kill_switch: true` in `evolution.yaml` — the selector",
        "will fall back to `incumbent_default` for every run until flipped back.",
    ]
    return "\n".join(lines) + "\n"
