"""EmergentDetector — population-level dynamics analysis for emergent behavior patterns.

AD-236: Monitors system-wide dynamics across ALL agents for emergent behavior:
- Hebbian weight topology → cooperation clusters
- Trust score trajectories → change-point detection
- Routing patterns → intent distribution shifts
- Dream consolidation → unusual strengthening/pruning
- Capability growth → rate of new intent types

Unlike BehavioralMonitor (which tracks individual self-created agents),
EmergentDetector analyzes population-level patterns. It is purely
observational — a reader, not a writer.
"""

from __future__ import annotations

import collections
import logging
import math
import time

from probos.config import format_trust
from dataclasses import dataclass, field
from enum import Enum
from typing import Any

from collections.abc import Callable

from probos.consensus.trust import TrustNetwork  # AD-399: allowed edge — reads trust for emergent pattern detection
from probos.mesh.routing import HebbianRouter, REL_INTENT, REL_SOCIAL

logger = logging.getLogger(__name__)


@dataclass
class EmergentPattern:
    """A detected emergent behavior pattern."""

    pattern_type: str       # "cooperation_cluster", "trust_anomaly",
                            # "routing_shift", "consolidation_anomaly",
                            # "capability_growth"
    description: str        # Human-readable description
    confidence: float       # 0.0-1.0
    evidence: dict = field(default_factory=dict)  # Supporting data
    timestamp: float = 0.0  # time.monotonic() — for in-memory dedup/cooldown
    severity: str = "info"  # "info", "notable", "significant"
    # BF #498: wall-clock timestamp + lifecycle phase for EventLog correlation.
    # `timestamp` (above) is monotonic and useless across processes/restarts;
    # `wall_time` is unix-epoch seconds and `phase_tag` records the runtime
    # lifecycle state ("first_boot", "reset", "stasis_recovery", "restart")
    # at detection time so consumers can causally order patterns.
    wall_time: float = 0.0
    phase_tag: str | None = None


@dataclass
class SystemDynamicsSnapshot:
    """Point-in-time snapshot of system-level metrics."""

    timestamp: float = 0.0
    tc_n: float = 0.0                                       # Total correlation proxy
    cooperation_clusters: list[dict] = field(default_factory=list)  # Agent groups that co-succeed
    trust_distribution: dict = field(default_factory=dict)   # mean, std, min, max, skew
    routing_entropy: float = 0.0                             # How evenly intents are distributed
    capability_count: int = 0                                # Number of distinct intent types handled
    dream_consolidation_rate: float = 0.0                    # Weights changed per dream cycle


class TrendDirection(str, Enum):
    RISING = "rising"
    STABLE = "stable"
    FALLING = "falling"


@dataclass
class MetricTrend:
    """Trend analysis for a single metric over N snapshots."""
    metric_name: str
    direction: TrendDirection
    slope: float  # rate of change per snapshot
    r_squared: float  # goodness of fit (0-1), indicates confidence
    current_value: float
    window_size: int  # how many snapshots were used
    significant: bool  # slope magnitude > threshold AND r_squared > 0.5


@dataclass
class TrendReport:
    """Multi-metric trend analysis over the snapshot ring buffer."""
    tc_n: MetricTrend
    routing_entropy: MetricTrend
    cluster_count: MetricTrend  # number of cooperation clusters
    trust_spread: MetricTrend  # std dev of trust distribution
    capability_count: MetricTrend
    significant_trends: list[MetricTrend]  # only trends where significant=True
    window_size: int
    timestamp: float


class EmergentDetector:
    """Monitors system dynamics for emergent behavior patterns.

    Unlike BehavioralMonitor (which tracks individual self-created agents),
    EmergentDetector analyzes population-level patterns across ALL agents:
    - Hebbian weight topology → cooperation clusters
    - Trust score trajectories → change-point detection
    - Routing patterns → intent distribution shifts
    - Dream consolidation → unusual strengthening/pruning
    - Capability growth → rate of new intent types
    """

    def __init__(
        self,
        hebbian_router: HebbianRouter,
        trust_network: TrustNetwork,
        episodic_memory: Any = None,
        max_history: int = 100,
        trend_threshold: float = 0.005,
        # BF-089: Configurable trust anomaly thresholds
        trust_sigma_threshold: float = 2.0,
        trust_sigma_significant: float = 3.0,
        trust_change_threshold: float = 0.15,
        trust_min_std: float = 0.05,
        trust_min_observations: float = 8.0,
        trust_min_deviation: float = 0.10,
        trust_ema_alpha: float = 0.3,
        trust_anomaly_window: float = 600.0,
        trust_anomaly_min_count: int = 3,
        max_trust_anomalies_per_pass: int = 3,
        duty_correlation_window: float = 120.0,
        # AD-556: Per-agent adaptive trust anomaly detection
        adaptive_window_size: int = 30,
        adaptive_z_threshold: float = 2.5,
        adaptive_debounce_count: int = 2,
        adaptive_min_history: int = 8,
        # BF-124: Configurable cooperation cluster thresholds
        cluster_edge_threshold: float = 0.3,
        cluster_min_size: int = 3,
        cluster_min_avg_weight: float = 0.25,
        cluster_cooldown_seconds: float = 1800.0,
        cluster_activity_window: float = 900.0,  # BF-165: 15 minutes
        dream_min_history: int = 5,  # BF-166
        # BF-175: Minimum absolute floors to prevent false positives at low baselines
        dream_anomaly_min_strengthened: int = 10,
        dream_anomaly_min_pruned: int = 5,
        dream_anomaly_min_trust_adj: int = 10,
        # BF #498: lifecycle phase getter for EventLog correlation.
        # Returns runtime._lifecycle_state (e.g. "first_boot", "reset",
        # "stasis_recovery", "restart") or None if unavailable. Used to stamp
        # phase_tag on every emitted EmergentPattern so warm-boot fragmentation
        # investigations can causally order patterns across stasis windows.
        phase_tag_getter: Callable[[], str | None] | None = None,
    ) -> None:
        self._router = hebbian_router
        self._trust = trust_network
        self._episodic_memory = episodic_memory
        self._max_history = max_history
        self._trend_threshold = trend_threshold
        self._phase_tag_getter = phase_tag_getter

        # BF-089: Trust anomaly detection thresholds
        self._trust_sigma_threshold = trust_sigma_threshold
        self._trust_sigma_significant = trust_sigma_significant
        self._trust_change_threshold = trust_change_threshold
        self._trust_min_std = trust_min_std
        self._trust_min_observations = trust_min_observations
        self._trust_min_deviation = trust_min_deviation
        self._trust_ema_alpha = trust_ema_alpha
        self._trust_anomaly_window = trust_anomaly_window
        self._trust_anomaly_min_count = trust_anomaly_min_count
        self._max_trust_anomalies_per_pass = max_trust_anomalies_per_pass
        self._duty_correlation_window = duty_correlation_window

        # AD-556: Per-agent adaptive trust anomaly detection
        self._adaptive_window_size = adaptive_window_size
        self._adaptive_z_threshold = adaptive_z_threshold
        self._adaptive_debounce_count = adaptive_debounce_count
        self._adaptive_min_history = adaptive_min_history

        # BF-124: Cooperation cluster detection thresholds
        self._cluster_edge_threshold = cluster_edge_threshold
        self._cluster_min_size = cluster_min_size
        self._cluster_min_avg_weight = cluster_min_avg_weight
        self._cluster_cooldown_seconds = cluster_cooldown_seconds

        # BF-089: Adaptive EMA baselines for sigma detection
        self._ema_trust_mean: float | None = None
        self._ema_trust_std: float | None = None

        # BF-089: Temporal buffer — per-agent anomaly observation timestamps
        self._trust_anomaly_counts: dict[str, list[float]] = {}

        # AD-556: Per-agent trust score history for adaptive z-score detection
        self._agent_trust_history: dict[str, list[float]] = {}  # agent_id → recent scores
        self._agent_anomaly_streak: dict[str, int] = {}  # agent_id → consecutive anomalous cycles

        # Live agent roster — set via set_live_agents() from runtime at startup.
        # When set, cooperation cluster detection filters out defunct agents.
        self._live_agent_ids: set[str] | None = None

        # Ring buffer of snapshots for trend analysis
        self._history: collections.deque[SystemDynamicsSnapshot] = collections.deque(maxlen=max_history)

        # All detected patterns (historical)
        self._all_patterns: list[EmergentPattern] = []

        # Previous snapshot for change detection
        self._prev_intent_agent_map: dict[str, set[str]] = {}

        # Dream report history for consolidation anomaly baselines
        # BF-166: Bounded ring buffer (matches _history pattern)
        self._dream_history: collections.deque[dict] = collections.deque(maxlen=max_history)
        self._dream_min_history = dream_min_history
        # BF-175: Absolute floors — suppress anomalies below these counts
        self._dream_anomaly_min_strengthened = dream_anomaly_min_strengthened
        self._dream_anomaly_min_pruned = dream_anomaly_min_pruned
        self._dream_anomaly_min_trust_adj = dream_anomaly_min_trust_adj

        # Pattern deduplication (AD-411): suppress duplicate patterns within cooldown
        self._pattern_cooldown_seconds: float = 600.0  # 10 minutes default
        self._last_pattern_fired: dict[tuple[str, str], float] = {}  # (pattern_type, dedup_key) → monotonic timestamp

        # BF-034: Cold-start suppression — suppress trust anomalies for N seconds after reset
        self._suppress_trust_until: float = 0.0

        # BF-126: Post-stasis cooperation cluster suppression — synchronized agent
        # startup creates false positive cooperation clusters
        self._suppress_clusters_until: float = 0.0

        # BF-166: Post-stasis suppression for consolidation anomaly detection
        self._suppress_dreams_until: float = 0.0

        # BF-165: Cognitive activity gate — suppress cluster detection when
        # no Hebbian interactions have occurred within the activity window.
        # Prevents false positives from stale weights during stasis.
        self._last_activity_time: float = 0.0
        self._cluster_activity_window: float = cluster_activity_window

        # BF-100: Dream cycle suppression — suppress trust anomalies during dream consolidation
        self._dreaming: bool = False

    def set_live_agents(self, agent_ids: set[str]) -> None:
        """Update the set of live agent IDs from the runtime pool registry."""
        self._live_agent_ids = agent_ids

    def set_pattern_cooldown(self, seconds: float) -> None:
        """Set the deduplication cooldown window (seconds)."""
        self._pattern_cooldown_seconds = max(0, seconds)

    def set_cold_start_suppression(self, duration_seconds: float) -> None:
        """Suppress trust anomaly, cooperation cluster, and dream consolidation
        anomaly detection after a cold start.

        BF-034: Trust anomalies suppressed since baseline trust (0.5) is expected.
        BF-126: Cooperation clusters suppressed since synchronized agent startup
        creates correlated Hebbian activity that looks like cooperation but is
        just simultaneous initialization. Routing shifts still fire.
        BF-166: Dream consolidation anomalies suppressed since first post-stasis
        dream cycles consolidate stale state with no meaningful baseline.
        """
        self._suppress_trust_until = time.monotonic() + duration_seconds
        self._suppress_clusters_until = time.monotonic() + duration_seconds
        self._suppress_dreams_until = time.monotonic() + duration_seconds

    def clear_dream_history(self) -> None:
        """Clear stale dream report history.

        BF-178: After stasis recovery, pre-stasis dream baselines cause
        false consolidation anomalies. Clearing forces dream_min_history
        gate to re-apply, requiring fresh baseline accumulation.
        """
        self._dream_history.clear()

    def recent_dreams(self, limit: int = 20) -> list[dict]:
        """AD-635: Public read accessor over the dream-report ring buffer.

        Returns up to `limit` most-recent dream reports as a list of dicts.
        Most recent last (FIFO order matches deque iteration). Returns a
        new list each call — callers may mutate without affecting state.
        """
        if limit <= 0:
            return []
        if limit >= len(self._dream_history):
            return list(self._dream_history)
        return list(self._dream_history)[-limit:]

    def record_activity(self) -> None:
        """Record that cognitive activity occurred (Hebbian interaction).

        BF-165: Called when the Hebbian router records an interaction,
        signaling that agents are actively processing — cooperation
        clusters found in this window reflect current behavior, not
        stale historical weights.
        """
        self._last_activity_time = time.monotonic()

    def set_dreaming(self, active: bool) -> None:
        """Set dream cycle state. Suppresses trust anomaly detection during dreams.

        BF-100: Dream consolidation (Step 3) updates trust scores via
        record_outcome(), and AD-558 now emits TRUST_UPDATE events for these.
        Without suppression, the detector treats coordinated dream trust
        movements as anomalies. Cooperation clusters and routing shifts are
        still detected — only trust anomalies are suppressed.
        """
        self._dreaming = active

    def _is_duplicate_pattern(self, pattern_type: str, dedup_key: str) -> bool:
        """Check if this pattern was already reported within the cooldown window.

        Returns True if this pattern should be suppressed (duplicate).
        """
        now = time.monotonic()
        cache_key = (pattern_type, dedup_key)
        last_fired = self._last_pattern_fired.get(cache_key)
        # BF-124: Per-type cooldown — structural patterns (cooperation clusters)
        # change slowly and don't need frequent re-alerting
        cooldown = self._pattern_cooldown_seconds
        if pattern_type == "cooperation_cluster":
            cooldown = self._cluster_cooldown_seconds
        if last_fired is not None and (now - last_fired) < cooldown:
            return True  # Suppress — fired too recently
        self._last_pattern_fired[cache_key] = now
        return False

    def _prune_stale_dedup_entries(self) -> None:
        """Remove expired entries from the dedup cache to prevent unbounded growth."""
        now = time.monotonic()
        # BF-124: Use max cooldown for pruning so cluster entries survive
        max_cooldown = max(self._pattern_cooldown_seconds, self._cluster_cooldown_seconds)
        cutoff = now - max_cooldown * 2
        stale_keys = [k for k, t in self._last_pattern_fired.items() if t < cutoff]
        for k in stale_keys:
            del self._last_pattern_fired[k]

    def analyze(self, dream_report: Any = None, duty_completions: list[tuple[str, float]] | None = None) -> list[EmergentPattern]:
        """Main analysis entry point. Runs all detectors and returns detected patterns."""
        now = time.monotonic()

        # AD-411: Prune expired dedup entries
        self._prune_stale_dedup_entries()

        # Cache episode total for early-session guards (AD-288)
        if dream_report is not None:
            replayed = getattr(dream_report, 'episodes_replayed', 0)
            if isinstance(dream_report, dict):
                replayed = dream_report.get('episodes_replayed', 0)
            self._cached_episode_total = getattr(self, '_cached_episode_total', 0) + replayed

        # Take snapshot
        snapshot = self.get_snapshot()

        # Store in history ring buffer
        self._history.append(snapshot)

        # Run detectors
        patterns: list[EmergentPattern] = []

        clusters = self.detect_cooperation_clusters(self._live_agent_ids)
        for cluster in clusters:
            # AD-411: Dedup by cluster membership (sorted agent IDs)
            members = sorted(cluster.get("members", []))
            dedup_key = ",".join(m[:8] for m in members) if members else str(cluster.get("size", 0))
            if self._is_duplicate_pattern("cooperation_cluster", dedup_key):
                continue
            patterns.append(EmergentPattern(
                pattern_type="cooperation_cluster",
                description=f"Cooperation cluster: {cluster['size']} nodes, avg weight {cluster['avg_weight']:.3f}",
                confidence=min(1.0, cluster["avg_weight"] * 2),
                evidence=cluster,
                timestamp=now,
                severity="notable" if cluster["size"] >= 3 else "info",
            ))

        patterns.extend(self.detect_trust_anomalies(duty_completions=duty_completions or []))
        patterns.extend(self.detect_routing_shifts())
        patterns.extend(self.detect_consolidation_anomalies(dream_report))

        # Trend regression over snapshot buffer (AD-380)
        trend_report = self.compute_trends()
        if trend_report and trend_report.significant_trends:
            patterns.append(EmergentPattern(
                pattern_type="emergence_trends",
                description=f"{len(trend_report.significant_trends)} significant trend(s) over {trend_report.window_size} snapshots",
                confidence=max(t.r_squared for t in trend_report.significant_trends),
                evidence={
                    "trends": [
                        {
                            "metric": t.metric_name,
                            "direction": t.direction.value,
                            "slope": round(t.slope, 6),
                            "r_squared": round(t.r_squared, 3),
                            "current": format_trust(t.current_value),
                        }
                        for t in trend_report.significant_trends
                    ],
                    "window_size": trend_report.window_size,
                },
                timestamp=time.monotonic(),
                severity="notable",
            ))

        # Store all detected patterns
        # BF #498: stamp wall_time + phase_tag on every pattern so EventLog
        # consumers can causally order them across processes/restarts. The
        # in-memory `timestamp` field is monotonic and useless across boots.
        _phase = self._phase_tag_getter() if self._phase_tag_getter else None
        _wall = time.time()
        for _p in patterns:
            if _p.wall_time == 0.0:
                _p.wall_time = _wall
            if _p.phase_tag is None:
                _p.phase_tag = _phase

        self._all_patterns.extend(patterns)
        # Cap pattern history
        if len(self._all_patterns) > 500:
            self._all_patterns = self._all_patterns[-500:]

        # Update previous intent→agent map for next routing shift detection
        self._prev_intent_agent_map = self._current_intent_agent_map()

        return patterns

    def compute_tc_n(self) -> float:
        """Adapted TC_N for single-mesh ProbOS.

        Measures system integration as the fraction of active relationships
        that span multiple pools. Two signal sources:

        1. **Intent routing** (REL_INTENT): intents that route to 2+ pools
        2. **Social interaction** (REL_SOCIAL): agents that communicate with
           agents from pools other than their own (Ward Room collaboration)

        Social interactions are unprompted agent-to-agent collaboration —
        emergent cross-department cooperation that TC_N should capture.
        """
        if not self._episodic_memory:
            return 0.0

        weights = self._router.all_weights_typed()

        # --- Intent signal: intents routing to 2+ pools ---
        intent_weights = {k: v for k, v in weights.items() if k[2] == REL_INTENT}
        intent_pools: dict[str, set[str]] = {}
        for (source, target, _), weight in intent_weights.items():
            if weight < 0.01:
                continue
            pool = self._extract_pool(target)
            if pool:
                intent_pools.setdefault(source, set()).add(pool)

        intent_multi = sum(1 for pools in intent_pools.values() if len(pools) >= 2)
        intent_total = len(intent_pools)

        # --- Social signal: agents communicating cross-pool ---
        social_weights = {k: v for k, v in weights.items() if k[2] == REL_SOCIAL}
        agent_cross_pool: dict[str, set[str]] = {}
        for (source, target, _), weight in social_weights.items():
            if weight < 0.01:
                continue
            source_pool = self._extract_pool(source)
            target_pool = self._extract_pool(target)
            if source_pool and target_pool:
                agent_cross_pool.setdefault(source, set()).add(target_pool)

        social_multi = 0
        social_total = 0
        for agent, target_pools in agent_cross_pool.items():
            source_pool = self._extract_pool(agent)
            if source_pool:
                social_total += 1
                if target_pools - {source_pool}:
                    social_multi += 1

        # Combined: fraction of all signals showing multi-pool cooperation
        total = intent_total + social_total
        if total == 0:
            return 0.0

        return (intent_multi + social_multi) / total

    def detect_cooperation_clusters(self, live_agent_ids: set[str] | None = None) -> list[dict]:
        """Analyze Hebbian weight graph for agent cooperation clusters.

        If *live_agent_ids* is provided, weights referencing agents not in the
        set are filtered out before cluster detection (prevents ghost clusters
        from agents removed by /reset).
        """
        # BF-126: Suppress during post-stasis window — synchronized startup
        # creates false positive cooperation clusters
        if time.monotonic() < self._suppress_clusters_until:
            return []

        # BF-165: Skip cluster detection if no cognitive activity within window.
        # Hebbian weights are persistent — without recent interactions, any
        # clusters found are stale reruns of historical cooperation patterns.
        # When _last_activity_time is 0.0 (never recorded), suppress regardless
        # of monotonic() value (which may be small on freshly-booted CI runners).
        if self._cluster_activity_window > 0:
            if self._last_activity_time <= 0.0:
                return []
            since_activity = time.monotonic() - self._last_activity_time
            if since_activity > self._cluster_activity_window:
                return []

        # Don't detect cooperation clusters until we have enough data (AD-288)
        if self._episodic_memory:
            try:
                # Use cached stats if available; avoid async call from sync method
                total = getattr(self, '_cached_episode_total', 0)
                if total < 10:
                    return []
            except Exception:
                logger.debug("Emergent detection context failed", exc_info=True)

        weights = self._router.all_weights_typed()
        intent_weights = {k: v for k, v in weights.items() if k[2] == REL_INTENT}

        # Filter out weights referencing defunct agents
        if live_agent_ids is not None:
            intent_weights = {
                k: v for k, v in intent_weights.items()
                if k[1] in live_agent_ids or not self._extract_pool(k[1])
            }

        if not intent_weights:
            return []

        # Build adjacency: group by shared source or target above threshold
        threshold = self._cluster_edge_threshold  # BF-124 (was 0.1)
        strong_edges: list[tuple[str, str, float]] = []
        for (source, target, _), weight in intent_weights.items():
            if weight >= threshold:
                strong_edges.append((source, target, weight))

        if not strong_edges:
            return []

        # Union-Find for connected components
        parent: dict[str, str] = {}

        def find(x: str) -> str:
            while parent.get(x, x) != x:
                parent[x] = parent.get(parent[x], parent[x])
                x = parent[x]
            return x

        def union(a: str, b: str) -> None:
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[ra] = rb

        all_nodes: set[str] = set()
        for src, tgt, _ in strong_edges:
            all_nodes.add(src)
            all_nodes.add(tgt)
            union(src, tgt)

        # Collect components
        components: dict[str, list[str]] = {}
        for node in all_nodes:
            root = find(node)
            components.setdefault(root, []).append(node)

        # Build cluster dicts
        clusters: list[dict] = []
        for members in components.values():
            member_set = set(members)
            cluster_weights = [
                w for (s, t, _), w in intent_weights.items()
                if s in member_set or t in member_set
            ]
            avg_weight = sum(cluster_weights) / len(cluster_weights) if cluster_weights else 0.0

            # Separate intents vs agents
            intents = [m for m in members if not self._extract_pool(m)]
            agents = [m for m in members if self._extract_pool(m)]

            clusters.append({
                "intents": intents,
                "agents": agents,
                "avg_weight": avg_weight,
                "size": len(members),
            })

        # BF-124: Filter clusters by minimum size and average weight
        clusters = [
            c for c in clusters
            if c["size"] >= self._cluster_min_size
            and c["avg_weight"] >= self._cluster_min_avg_weight
        ]

        return clusters

    def _prune_stale_anomaly_counts(self) -> None:
        """Remove anomaly count entries older than the temporal window."""
        cutoff = time.monotonic() - self._trust_anomaly_window
        for agent_id in list(self._trust_anomaly_counts):
            self._trust_anomaly_counts[agent_id] = [
                t for t in self._trust_anomaly_counts[agent_id] if t > cutoff
            ]
            if not self._trust_anomaly_counts[agent_id]:
                del self._trust_anomaly_counts[agent_id]

    def _record_anomaly_observation(self, agent_key: str) -> bool:
        """Record a trust anomaly observation and return True if it should be promoted.

        An observation is promoted to a pattern only if the agent has triggered
        at least ``_trust_anomaly_min_count`` times within ``_trust_anomaly_window``.
        """
        now = time.monotonic()
        self._trust_anomaly_counts.setdefault(agent_key, []).append(now)
        count = len(self._trust_anomaly_counts[agent_key])
        if count >= self._trust_anomaly_min_count:
            return True
        logger.debug(
            "Trust anomaly observation for %s (%d/%d within window) — suppressed",
            agent_key, count, self._trust_anomaly_min_count,
        )
        return False

    def _update_agent_trust_history(self, agent_id: str, score: float) -> None:
        """AD-556: Update per-agent trust score history for adaptive detection."""
        history = self._agent_trust_history.setdefault(agent_id, [])
        history.append(score)
        # Maintain rolling window
        if len(history) > self._adaptive_window_size:
            self._agent_trust_history[agent_id] = history[-self._adaptive_window_size:]

    def _compute_agent_z_score(self, agent_id: str, current_score: float) -> float | None:
        """AD-556: Compute z-score of current trust delta against agent's personal baseline.

        Returns None if insufficient history for meaningful z-score.
        """
        history = self._agent_trust_history.get(agent_id, [])
        if len(history) < self._adaptive_min_history:
            return None  # Not enough data for personal baseline

        # Compute deltas between consecutive history entries
        deltas = [history[i] - history[i - 1] for i in range(1, len(history))]
        if not deltas:
            return None

        delta_mean = sum(deltas) / len(deltas)
        delta_variance = sum((d - delta_mean) ** 2 for d in deltas) / len(deltas)
        delta_std = math.sqrt(delta_variance) if delta_variance > 0 else 0.0

        if delta_std < 0.001:
            # Agent has nearly zero variance — any non-trivial delta is anomalous
            # Use absolute threshold instead of z-score
            current_delta = current_score - history[-1] if history else 0.0
            if abs(current_delta) > self._trust_min_deviation:
                return 10.0  # Synthetic high z-score for stable agents with sudden change
            return 0.0

        # Z-score of the current delta relative to personal baseline
        current_delta = current_score - history[-1] if history else 0.0
        z = (current_delta - delta_mean) / delta_std
        return abs(z)

    def _check_adaptive_debounce(self, agent_id: str, z_score: float) -> bool:
        """AD-556: Check if anomalous z-score persists across consecutive cycles.

        Returns True if the anomaly should be promoted (debounce satisfied).
        """
        if z_score >= self._adaptive_z_threshold:
            streak = self._agent_anomaly_streak.get(agent_id, 0) + 1
            self._agent_anomaly_streak[agent_id] = streak
            return streak >= self._adaptive_debounce_count
        else:
            # Reset streak — anomaly did not persist
            self._agent_anomaly_streak.pop(agent_id, None)
            return False

    def detect_trust_anomalies(self, duty_completions: list[tuple[str, float]] | None = None) -> list[EmergentPattern]:
        """Detect agents whose trust deviates significantly from the population."""
        now = time.monotonic()
        patterns: list[EmergentPattern] = []

        # BF-034: Suppress during cold-start period
        if time.monotonic() < self._suppress_trust_until:
            return patterns

        # BF-100: Suppress during dream cycles — dream consolidation trust
        # updates are legitimate coordination, not anomalies
        if self._dreaming:
            return patterns

        # BF-089: Prune stale temporal buffer entries
        self._prune_stale_anomaly_counts()

        # Build duty completion lookup: agent_id → latest completion timestamp
        duty_map: dict[str, float] = {}
        for agent_id, ts in (duty_completions or []):
            if agent_id not in duty_map or ts > duty_map[agent_id]:
                duty_map[agent_id] = ts

        raw = self._trust.raw_scores()
        if len(raw) < 2:
            return patterns

        # Compute population statistics
        scores = [r["alpha"] / (r["alpha"] + r["beta"]) for r in raw.values()]
        current_mean = sum(scores) / len(scores)
        variance = sum((s - current_mean) ** 2 for s in scores) / len(scores)
        current_std = math.sqrt(variance) if variance > 0 else 0.0

        # BF-089: Update adaptive EMA baselines
        alpha = self._trust_ema_alpha
        if self._ema_trust_mean is None:
            self._ema_trust_mean = current_mean
            self._ema_trust_std = current_std
        else:
            self._ema_trust_mean = alpha * current_mean + (1 - alpha) * self._ema_trust_mean
            self._ema_trust_std = alpha * current_std + (1 - alpha) * (self._ema_trust_std or current_std)

        # Use EMA values for sigma detection
        mean = self._ema_trust_mean
        std = self._ema_trust_std or current_std

        if std < self._trust_min_std:
            # Population trust spread too narrow for sigma analysis to be meaningful.
            pass
        else:
            # Flag agents > threshold sigma from mean
            for agent_id, record in raw.items():
                score = record["alpha"] / (record["alpha"] + record["beta"])

                # Skip agents with too few observations — trust hasn't stabilized
                total_observations = record["alpha"] + record["beta"]
                if total_observations < self._trust_min_observations:
                    continue

                # Skip if absolute deviation is negligible regardless of sigma
                abs_deviation = abs(score - mean)
                if abs_deviation < self._trust_min_deviation:
                    continue

                deviation = abs_deviation / std

                # BF-089: Duty correlation — elevate sigma threshold during duty cycle
                agent_duty_ts = duty_map.get(agent_id)
                effective_sigma = self._trust_sigma_threshold
                if agent_duty_ts is not None and (now - agent_duty_ts) < self._duty_correlation_window:
                    effective_sigma = self._trust_sigma_significant

                if deviation > effective_sigma:
                    direction = "high" if score > mean else "low"
                    severity = "significant" if deviation > self._trust_sigma_significant else "notable"

                    # AD-556: Per-agent adaptive z-score gate
                    z_score = self._compute_agent_z_score(agent_id, score)
                    if z_score is not None:
                        # Agent has enough personal history — use adaptive detection
                        if not self._check_adaptive_debounce(agent_id, z_score):
                            logger.debug(
                                "AD-556: Trust anomaly for %s suppressed by adaptive gate "
                                "(z=%.2f, threshold=%.1f, streak=%d/%d)",
                                agent_id[:8], z_score, self._adaptive_z_threshold,
                                self._agent_anomaly_streak.get(agent_id, 0),
                                self._adaptive_debounce_count,
                            )
                            continue
                    else:
                        # AD-556: Not enough personal history — fall back to population-only detection
                        # BF-089: Temporal buffer — require sustained anomaly before emitting
                        anomaly_key = f"sigma:{agent_id}:{direction}"
                        if not self._record_anomaly_observation(anomaly_key):
                            continue

                    # AD-411: Suppress duplicate trust anomaly for same agent+direction
                    dedup_key = f"{agent_id}:{direction}"
                    if self._is_duplicate_pattern("trust_anomaly", dedup_key):
                        continue

                    # Causal back-references (AD-295c)
                    recent_events = self._trust.get_events_for_agent(agent_id, n=5)
                    causal_events = [
                        {
                            "intent_type": event.intent_type,
                            "success": event.success,
                            "weight": format_trust(event.weight),
                            "score_change": format_trust(event.new_score - event.old_score),
                            "episode_id": event.episode_id,
                        }
                        for event in recent_events
                    ]
                    # AD-556: Include per-agent z-score in evidence
                    adaptive_info = {}
                    if z_score is not None:
                        history = self._agent_trust_history.get(agent_id, [])
                        adaptive_info = {
                            "personal_z_score": round(z_score, 2),
                            "personal_history_len": len(history),
                            "detection_mode": "adaptive",
                        }
                    else:
                        adaptive_info = {"detection_mode": "population_only"}

                    patterns.append(EmergentPattern(
                        pattern_type="trust_anomaly",
                        description=f"Agent {agent_id[:8]} has {direction} trust ({score:.3f}) — {deviation:.1f}σ from mean ({mean:.3f})",
                        confidence=min(1.0, deviation / 4.0),
                        evidence={
                            "agent_id": agent_id,
                            "score": score,
                            "mean": mean,
                            "std": std,
                            "deviation_sigma": deviation,
                            "direction": direction,
                            "causal_events": causal_events,
                            **adaptive_info,
                        },
                        timestamp=now,
                        severity=severity,
                    ))

        # Flag hyperactive agents (high observation count relative to population)
        observations = [r["observations"] for r in raw.values()]
        obs_mean = sum(observations) / len(observations) if observations else 0.0
        obs_std = math.sqrt(
            sum((o - obs_mean) ** 2 for o in observations) / len(observations)
        ) if len(observations) > 1 else 0.0

        if obs_std > 0.001:
            for agent_id, record in raw.items():
                obs = record["observations"]
                if obs_mean > 0 and obs > obs_mean + 2 * obs_std:
                    # AD-411: Suppress duplicate hyperactivity alerts
                    if self._is_duplicate_pattern("trust_anomaly", f"hyperactive:{agent_id}"):
                        continue
                    patterns.append(EmergentPattern(
                        pattern_type="trust_anomaly",
                        description=f"Agent {agent_id[:8]} is hyperactive — {obs:.0f} observations vs population mean {obs_mean:.0f}",
                        confidence=0.7,
                        evidence={
                            "agent_id": agent_id,
                            "observations": obs,
                            "population_mean": obs_mean,
                            "population_std": obs_std,
                        },
                        timestamp=now,
                        severity="info",
                    ))

        # Change-point detection: compare against previous snapshot
        if self._history:
            prev_snapshot = self._history[-1]
            prev_trust = prev_snapshot.trust_distribution
            if prev_trust:
                prev_scores = {}
                # Reconstruct previous per-agent scores from raw data
                for agent_id, record in raw.items():
                    prev_record_score = prev_trust.get("per_agent", {}).get(agent_id)
                    if prev_record_score is not None:
                        current_score = record["alpha"] / (record["alpha"] + record["beta"])
                        delta = abs(current_score - prev_record_score)
                        if delta > self._trust_change_threshold:
                            # BF-089: Duty correlation — skip change-point during duty cycle
                            agent_duty_ts = duty_map.get(agent_id)
                            if agent_duty_ts is not None and (now - agent_duty_ts) < self._duty_correlation_window:
                                logger.debug(
                                    "Change-point for %s suppressed — duty completed %.1fs ago",
                                    agent_id[:8], now - agent_duty_ts,
                                )
                                continue

                            # BF-089: Temporal buffer for change-point
                            cp_key = f"changepoint:{agent_id}"
                            if not self._record_anomaly_observation(cp_key):
                                continue

                            direction = "increased" if current_score > prev_record_score else "decreased"
                            # AD-411: Suppress duplicate change-point alerts
                            if self._is_duplicate_pattern("trust_anomaly", f"changepoint:{agent_id}"):
                                continue
                            patterns.append(EmergentPattern(
                                pattern_type="trust_anomaly",
                                description=f"Agent {agent_id[:8]} trust {direction} by {delta:.3f} (change point)",
                                confidence=min(1.0, delta / 0.3),
                                evidence={
                                    "agent_id": agent_id,
                                    "previous_score": prev_record_score,
                                    "current_score": current_score,
                                    "delta": delta,
                                },
                                timestamp=now,
                                severity="notable",
                            ))

        # BF-089: Batch limiting — keep only top N by confidence
        if len(patterns) > self._max_trust_anomalies_per_pass:
            total = len(patterns)
            patterns.sort(key=lambda p: p.confidence, reverse=True)
            patterns = patterns[:self._max_trust_anomalies_per_pass]
            logger.info(
                "Trust anomaly detection: %d candidates, reporting top %d",
                total, self._max_trust_anomalies_per_pass,
            )

        # AD-556: Update per-agent trust score history AFTER detection
        # (must happen after z-score computation so current score isn't in history yet)
        for agent_id, record in raw.items():
            agent_score = record["alpha"] / (record["alpha"] + record["beta"])
            self._update_agent_trust_history(agent_id, agent_score)

        return patterns

    def detect_routing_shifts(self) -> list[EmergentPattern]:
        """Detect when agents start handling intent types they haven't handled before."""
        now = time.monotonic()
        patterns: list[EmergentPattern] = []

        current_map = self._current_intent_agent_map()

        if not self._prev_intent_agent_map:
            return patterns

        # Find new connections
        for intent, agents in current_map.items():
            prev_agents = self._prev_intent_agent_map.get(intent, set())
            new_agents = agents - prev_agents
            for agent in new_agents:
                # AD-411: Suppress duplicate routing shift alerts
                if self._is_duplicate_pattern("routing_shift", f"{agent[:8]}:{intent}"):
                    continue
                # Include trust and Hebbian context (AD-295c)
                agent_trust = self._trust.get_score(agent)
                hebbian_weight = self._router.get_weight(intent, agent, rel_type=REL_INTENT)
                patterns.append(EmergentPattern(
                    pattern_type="routing_shift",
                    description=f"New routing: agent {agent[:8]} now handles '{intent}'",
                    confidence=0.8,
                    evidence={
                        "intent": intent,
                        "agent": agent,
                        "is_new_connection": True,
                        "agent_trust": format_trust(agent_trust),
                        "hebbian_weight": format_trust(hebbian_weight),
                    },
                    timestamp=now,
                    severity="notable",
                ))

        # Check for new intents that didn't exist before
        new_intents = set(current_map.keys()) - set(self._prev_intent_agent_map.keys())
        for intent in new_intents:
            if self._is_duplicate_pattern("routing_shift", f"new_intent:{intent}"):
                continue
            patterns.append(EmergentPattern(
                pattern_type="routing_shift",
                description=f"New intent type appeared: '{intent}'",
                confidence=0.9,
                evidence={
                    "intent": intent,
                    "agents": list(current_map[intent]),
                    "is_new_intent": True,
                },
                timestamp=now,
                severity="notable",
            ))

        # Entropy change detection
        if len(self._history) >= 2:
            prev_entropy = self._history[-2].routing_entropy
            current_entropy = self.compute_routing_entropy()
            entropy_delta = abs(current_entropy - prev_entropy)
            if entropy_delta > 0.5 and prev_entropy > 0:
                direction = "increased" if current_entropy > prev_entropy else "decreased"
                # AD-411: Quantize to 0.5 buckets to avoid near-duplicate entropy alerts
                bucket = f"entropy:{direction}:{round(current_entropy * 2) / 2:.1f}"
                if not self._is_duplicate_pattern("routing_shift", bucket):
                    patterns.append(EmergentPattern(
                        pattern_type="routing_shift",
                        description=f"Routing entropy {direction} by {entropy_delta:.2f} ({prev_entropy:.2f} → {current_entropy:.2f})",
                        confidence=min(1.0, entropy_delta / 1.0),
                        evidence={
                            "previous_entropy": prev_entropy,
                            "current_entropy": current_entropy,
                            "delta": entropy_delta,
                        },
                        timestamp=now,
                        severity="significant" if entropy_delta > 1.0 else "notable",
                    ))

        return patterns

    def detect_consolidation_anomalies(self, dream_report: Any = None) -> list[EmergentPattern]:
        """Check dream reports for unusual consolidation patterns."""
        now = time.monotonic()
        patterns: list[EmergentPattern] = []

        # BF-166: Suppress during post-stasis window — first dream cycles
        # consolidate stale state with no meaningful baseline
        if time.monotonic() < self._suppress_dreams_until:
            return patterns

        if dream_report is None:
            return patterns

        # Store dream report for baseline computation
        if isinstance(dream_report, dict):
            report_data = {
                "weights_strengthened": dream_report.get("weights_strengthened", 0),
                "weights_pruned": dream_report.get("weights_pruned", 0),
                "trust_adjustments": dream_report.get("trust_adjustments", 0),
                "pre_warm_intents": dream_report.get("pre_warm_intents", []),
            }
        else:
            report_data = {
                "weights_strengthened": getattr(dream_report, "weights_strengthened", 0),
                "weights_pruned": getattr(dream_report, "weights_pruned", 0),
                "trust_adjustments": getattr(dream_report, "trust_adjustments", 0),
                "pre_warm_intents": getattr(dream_report, "pre_warm_intents", []),
            }
        self._dream_history.append(report_data)

        # BF-166: Require enough dream reports for a meaningful baseline.
        # With only 1-2 reports, any variance triggers a false 2x anomaly.
        if len(self._dream_history) < self._dream_min_history:
            return patterns

        # Compute historical averages (excluding current)
        history = list(self._dream_history)[:-1]
        avg_strengthened = sum(d["weights_strengthened"] for d in history) / len(history)
        avg_pruned = sum(d["weights_pruned"] for d in history) / len(history)
        avg_trust_adj = sum(d["trust_adjustments"] for d in history) / len(history)

        # Flag anomalies: > 2x historical average AND above absolute floor (BF-175)
        strengthened = report_data["weights_strengthened"]
        if avg_strengthened > 0 and strengthened > 2 * avg_strengthened and strengthened >= self._dream_anomaly_min_strengthened:
            patterns.append(EmergentPattern(
                pattern_type="consolidation_anomaly",
                description=f"Unusual strengthening: {strengthened} weights (avg: {avg_strengthened:.0f})",
                confidence=min(1.0, strengthened / (3 * avg_strengthened)),
                evidence={
                    "weights_strengthened": strengthened,
                    "historical_average": avg_strengthened,
                    "ratio": strengthened / avg_strengthened,
                },
                timestamp=now,
                severity="notable",
            ))

        pruned = report_data["weights_pruned"]
        if avg_pruned > 0 and pruned > 2 * avg_pruned and pruned >= self._dream_anomaly_min_pruned:
            patterns.append(EmergentPattern(
                pattern_type="consolidation_anomaly",
                description=f"Unusual pruning: {pruned} connections (avg: {avg_pruned:.0f})",
                confidence=min(1.0, pruned / (3 * avg_pruned)),
                evidence={
                    "weights_pruned": pruned,
                    "historical_average": avg_pruned,
                    "ratio": pruned / avg_pruned,
                },
                timestamp=now,
                severity="notable",
            ))

        trust_adj = report_data["trust_adjustments"]
        if avg_trust_adj > 0 and trust_adj > 2 * avg_trust_adj and trust_adj >= self._dream_anomaly_min_trust_adj:
            patterns.append(EmergentPattern(
                pattern_type="consolidation_anomaly",
                description=f"Unusual trust adjustments: {trust_adj} (avg: {avg_trust_adj:.0f})",
                confidence=min(1.0, trust_adj / (3 * avg_trust_adj)),
                evidence={
                    "trust_adjustments": trust_adj,
                    "historical_average": avg_trust_adj,
                    "ratio": trust_adj / avg_trust_adj,
                },
                timestamp=now,
                severity="notable",
            ))

        return patterns

    def compute_routing_entropy(self) -> float:
        """Compute Shannon entropy over Hebbian weight distribution across pools.

        BF (parallel to BF-176 for compute_tc_n): aggregates BOTH intent routing
        (REL_INTENT) and social cross-pool collaboration (REL_SOCIAL). When crew
        agents are mostly producing Ward Room / DM activity (REL_SOCIAL) and
        intent-bus broadcasts are rare, an INTENT-only signal collapses to a
        single pool → Shannon entropy = 0. Including REL_SOCIAL keeps the
        metric meaningful as the crew shifts toward agent-to-agent
        collaboration as its dominant routing mode.
        """
        weights = self._router.all_weights_typed()
        intent_weights = {k: v for k, v in weights.items() if k[2] == REL_INTENT}
        social_weights = {k: v for k, v in weights.items() if k[2] == REL_SOCIAL}

        # Sum weights by target agent pool — intent + social combined
        pool_totals: dict[str, float] = {}
        for (_source, target, _), weight in intent_weights.items():
            pool = self._extract_pool(target)
            if pool:
                pool_totals[pool] = pool_totals.get(pool, 0.0) + weight
        for (_source, target, _), weight in social_weights.items():
            pool = self._extract_pool(target)
            if pool:
                pool_totals[pool] = pool_totals.get(pool, 0.0) + weight

        total = sum(pool_totals.values())
        if total == 0:
            return 0.0

        entropy = 0.0
        for w in pool_totals.values():
            p = w / total
            if p > 0:
                entropy -= p * math.log2(p)
        return entropy

    def get_snapshot(self) -> SystemDynamicsSnapshot:
        """Assemble a current snapshot from all metrics."""
        # Trust distribution
        raw_scores = self._trust.raw_scores()
        scores = [r["alpha"] / (r["alpha"] + r["beta"]) for r in raw_scores.values()]

        trust_dist: dict[str, Any] = {}
        per_agent: dict[str, float] = {}
        if scores:
            mean = sum(scores) / len(scores)
            variance = sum((s - mean) ** 2 for s in scores) / len(scores)
            std = math.sqrt(variance) if variance > 0 else 0.0
            trust_dist = {
                "mean": mean,
                "std": std,
                "min": min(scores),
                "max": max(scores),
            }
            for agent_id, record in raw_scores.items():
                per_agent[agent_id] = record["alpha"] / (record["alpha"] + record["beta"])
            trust_dist["per_agent"] = per_agent

        # Capability count from intent weights
        weights = self._router.all_weights_typed()
        intent_types = set()
        for (source, target, rel_type), _ in weights.items():
            if rel_type == REL_INTENT:
                intent_types.add(source)

        # Dream consolidation rate from history
        dream_rate = 0.0
        if self._dream_history:
            last = self._dream_history[-1]
            dream_rate = last.get("weights_strengthened", 0) + last.get("weights_pruned", 0)

        return SystemDynamicsSnapshot(
            timestamp=time.monotonic(),
            tc_n=self.compute_tc_n(),
            cooperation_clusters=self.detect_cooperation_clusters(self._live_agent_ids),
            trust_distribution=trust_dist,
            routing_entropy=self.compute_routing_entropy(),
            capability_count=len(intent_types),
            dream_consolidation_rate=dream_rate,
        )

    def summary(self) -> dict:
        """Return a JSON-serializable summary of the current state."""
        # Get counts from latest analysis or compute fresh
        latest_patterns = self._all_patterns[-5:] if self._all_patterns else []

        # Count by type from latest patterns
        type_counts: dict[str, int] = {}
        for p in self._all_patterns:
            type_counts[p.pattern_type] = type_counts.get(p.pattern_type, 0) + 1

        tc_n = self.compute_tc_n()
        entropy = self.compute_routing_entropy()

        return {
            "tc_n": tc_n,
            "routing_entropy": entropy,
            "cooperation_clusters": type_counts.get("cooperation_cluster", 0),
            "trust_anomalies": type_counts.get("trust_anomaly", 0),
            "routing_shifts": type_counts.get("routing_shift", 0),
            "consolidation_anomalies": type_counts.get("consolidation_anomaly", 0),
            "snapshots_recorded": len(self._history),
            "patterns_detected": len(self._all_patterns),
            "latest_patterns": [
                {
                    "pattern_type": p.pattern_type,
                    "description": p.description,
                    "confidence": p.confidence,
                    "severity": p.severity,
                    "timestamp": p.timestamp,
                }
                for p in latest_patterns
            ],
        }

    # ------------------------------------------------------------------
    # Trend regression (AD-380)
    # ------------------------------------------------------------------

    @staticmethod
    def _linear_regression(xs: list[float], ys: list[float]) -> tuple[float, float, float]:
        """Simple linear regression. Returns (slope, intercept, r_squared).

        Pure Python — no numpy dependency.
        """
        n = len(xs)
        if n < 2:
            return 0.0, 0.0, 0.0

        sum_x = sum(xs)
        sum_y = sum(ys)
        sum_xy = sum(x * y for x, y in zip(xs, ys))
        sum_x2 = sum(x * x for x in xs)

        denom = n * sum_x2 - sum_x * sum_x
        if abs(denom) < 1e-15:
            return 0.0, sum_y / n if n else 0.0, 0.0

        slope = (n * sum_xy - sum_x * sum_y) / denom
        intercept = (sum_y - slope * sum_x) / n

        # R-squared
        y_mean = sum_y / n
        ss_tot = sum((y - y_mean) ** 2 for y in ys)
        ss_res = sum((y - (slope * x + intercept)) ** 2 for x, y in zip(xs, ys))

        r_squared = 1.0 - (ss_res / ss_tot) if ss_tot > 1e-15 else 0.0
        # Clamp to [0, 1] for numerical stability
        r_squared = max(0.0, min(1.0, r_squared))

        return slope, intercept, r_squared

    def compute_trends(self, min_window: int = 20) -> TrendReport | None:
        """Compute trend regression over the snapshot ring buffer.

        Returns None if fewer than min_window snapshots are available.
        Uses simple linear regression (no numpy dependency).
        """
        history = list(self._history)
        if len(history) < min_window:
            return None

        window = history[-min_window:]
        xs = [float(i) for i in range(len(window))]

        def _extract_series(extract_fn: Any) -> list[float]:
            return [extract_fn(s) for s in window]

        def _make_trend(name: str, values: list[float]) -> MetricTrend:
            slope, _intercept, r_sq = self._linear_regression(xs, values)
            if slope > self._trend_threshold:
                direction = TrendDirection.RISING
            elif slope < -self._trend_threshold:
                direction = TrendDirection.FALLING
            else:
                direction = TrendDirection.STABLE
            significant = (
                abs(slope) > self._trend_threshold
                and r_sq > 0.5
                and len(values) >= min_window
            )
            return MetricTrend(
                metric_name=name,
                direction=direction,
                slope=slope,
                r_squared=r_sq,
                current_value=values[-1],
                window_size=len(values),
                significant=significant,
            )

        tc_n_trend = _make_trend("tc_n", _extract_series(lambda s: s.tc_n))
        entropy_trend = _make_trend("routing_entropy", _extract_series(lambda s: s.routing_entropy))
        cluster_trend = _make_trend("cluster_count", _extract_series(lambda s: float(len(s.cooperation_clusters))))
        trust_spread_trend = _make_trend("trust_spread", _extract_series(
            lambda s: s.trust_distribution.get("std", 0.0) if s.trust_distribution else 0.0
        ))
        capability_trend = _make_trend("capability_count", _extract_series(lambda s: float(s.capability_count)))

        all_trends = [tc_n_trend, entropy_trend, cluster_trend, trust_spread_trend, capability_trend]
        significant = [t for t in all_trends if t.significant]

        return TrendReport(
            tc_n=tc_n_trend,
            routing_entropy=entropy_trend,
            cluster_count=cluster_trend,
            trust_spread=trust_spread_trend,
            capability_count=capability_trend,
            significant_trends=significant,
            window_size=len(window),
            timestamp=time.monotonic(),
        )

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _extract_pool(agent_id: str) -> str:
        """Extract pool name from a deterministic agent ID.

        Uses parse_agent_id() from identity.py for reliable parsing,
        with a fallback for IDs not in the registry.
        """
        from probos.substrate.identity import parse_agent_id

        parsed = parse_agent_id(agent_id)
        if parsed and parsed.get("pool_name"):
            return parsed["pool_name"]
        # Fallback: return the prefix segments minus last two (index_hash)
        parts = agent_id.split("_")
        if len(parts) >= 4:
            return "_".join(parts[:-2])  # everything except index and hash
        return ""

    def _current_intent_agent_map(self) -> dict[str, set[str]]:
        """Build current mapping of intent -> set of agent IDs from Hebbian weights."""
        weights = self._router.all_weights_typed()
        mapping: dict[str, set[str]] = {}
        for (source, target, rel_type), weight in weights.items():
            if rel_type == REL_INTENT and weight >= 0.01:
                mapping.setdefault(source, set()).add(target)
        return mapping
