"""
kaskad/engine.py — Generative Cascade Engine.

The most interesting file in the project: a self-referential DAG engine
that implements AX23 (cascade structure) while itself BEING a cascade
product (the framework's inference graph IS a kaskad applied reflexively).

This module provides:
  1. CascadeDAG — typed DAG with 6 edge morphisms (§IG.1)
  2. Topological operations — sort, reachability, depth, critical paths
  3. T8 Neighborliness — predict detectable nodes from observed ones
  4. Tesanüd detection — super-additive convergence across chains (AX63)
  5. Hub & bridge analysis — §IG.3 cross-chain connection points
  6. Self-diagnostic — the engine validates its OWN structural integrity'

Governing axioms:
  AX23:  kaskad: S_Isim × S_Isim → Bool (transitive DAG)
  T7:    Creation Motor — Beauty desires mirrors
  T8:    Cascade Neighborliness — detection predicts neighbors
  AX63:  Tesanüd–İnfirâd asymmetry
  KV₄:   0 < Composite < 1
  KV₇:   Independence (no shared state between chains)
  AX57:  Transparency — mandatory disclosure
"""

from __future__ import annotations

from collections import defaultdict, deque
from typing import (
    Dict,
    FrozenSet,
    Iterable,
    List,
    Optional,
    Sequence,
    Set,
    Tuple,
)

from kaskad.types import (
    CascadeEdge,
    CascadeNode,
    CascadeReport,
    ChainId,
    ChainIntegrity,
    DynamicEdge,
    EdgeType,
    HubAnalysis,
    NeighborhoodPrediction,
    NodeKind,
    TesanudResult,
)


# ========================================================================
# Constants — structural bounds (mirroring bileshke's philosophy)
# ========================================================================

# KV₄ hard cap: cascade analysis participates in truth < 1.0
MAX_STRUCTURAL_COMPLETENESS: float = 0.9999

# T8 base confidence for single-hop neighborhood prediction
T8_BASE_CONFIDENCE: float = 0.85

# T8 decay per hop: confidence attenuates with cascade distance
T8_HOP_DECAY: float = 0.15

# AX63: minimum independent chains for tesanüd (super-additive)
MIN_TESANUD_CHAINS: int = 2

# Phase 7 — Minimum activation for edges without content evidence
# A structural edge is always real, even absent content evidence
MIN_EDGE_ACTIVATION: float = 0.3


# ========================================================================
# Chain ↔ Vocabulary / Lens mapping (Phase 7 — BS-19 / BS-20)
# ========================================================================

# Maps each chain to vocabulary tokens that indicate its presence in text
_CHAIN_VOCAB: Dict[ChainId, FrozenSet[str]] = {
    ChainId.C1: frozenset({
        "fakr", "acz", "poverty", "impotence", "dependency",
        "provision", "rahmet", "kudret", "contingency",
    }),
    ChainId.C2: frozenset({
        "beauty", "cemal", "creation", "mirror", "love",
        "desire", "cascade", "compassion", "teveddud",
    }),
    ChainId.C3: frozenset({
        "holographic", "participation", "convergence", "fidelity",
        "ne ayn ne gayr", "tereşşuhat", "besmele", "seed",
    }),
    ChainId.C4: frozenset({
        "faculty", "latife", "coverage", "completeness",
        "incompleteness", "ahfa", "inaccessible", "bound",
    }),
    ChainId.C5: frozenset({
        "station", "hierarchy", "vahiy", "ilham", "sunuhat",
        "istidlal", "edeb", "calibration", "epistemic",
    }),
    ChainId.C6: frozenset({
        "name", "esma", "harfi", "ismi", "dual", "meaning",
        "grounding", "hakikat", "reality",
    }),
    ChainId.C7: frozenset({
        "degree", "continuous", "supremum", "ekmel",
        "unreachable", "spectrum", "dense", "bound",
    }),
    ChainId.BEAUTY_CASCADE: frozenset({
        "beauty", "cemal", "kemal", "terahhum", "rahmet",
        "teveddud", "lutuf", "irade", "sun", "ilim", "tanzim",
    }),
}

# Maps chains to their most associated lens IDs (for lens_score lookup)
_CHAIN_LENS: Dict[ChainId, str] = {
    ChainId.C1: "fol_formalizasyon",
    ChainId.C2: "kavram_sozlugu",
    ChainId.C3: "holografik",
    ChainId.C4: "mereoloji",
    ChainId.C5: "bayes_analiz",
    ChainId.C6: "kavram_sozlugu",
    ChainId.C7: "kategori_teorisi",
    ChainId.BEAUTY_CASCADE: "kavram_sozlugu",
}


def activate_edges(
    dag: "CascadeDAG",
    vocab_hits: Optional[Sequence[str]] = None,
    lens_scores: Optional[Dict[str, float]] = None,
) -> Dict[Tuple[str, str, str], DynamicEdge]:
    """
    Activate/deactivate cascade edges based on content evidence.

    Phase 7 (BS-19): The DAG topology is axiomatic (AX23) and never
    changes.  Only edge ACTIVATION is dynamic, modulated by:
      1. Which vocabulary patterns were detected in the text
      2. Which lens scores were computed

    Returns a dict keyed by (source, target, edge_type.value) →
    DynamicEdge.  This is a *view* over the frozen DAG — the DAG
    itself remains immutable.

    Algorithm:
        For each edge (s → t):
          - Collect chains that contain BOTH s and t
          - If any chain's vocabulary appears in vocab_hits → activation = 1.0
          - Else if any chain's lens scored > 0.5 → activation = lens_score
          - Else → activation = MIN_EDGE_ACTIVATION (0.3)
    """
    hits_lower = frozenset(
        h.lower() for h in (vocab_hits or [])
    )
    scores = lens_scores or {}

    result: Dict[Tuple[str, str, str], DynamicEdge] = {}

    for edge in dag.get_edges():
        # Which chains contain both source and target?
        src_node = dag.get_node(edge.source)
        tgt_node = dag.get_node(edge.target)
        if src_node is None or tgt_node is None:
            # Defensive: edge endpoints should always exist
            result[(edge.source, edge.target, edge.edge_type.value)] = (
                DynamicEdge.from_edge(edge, MIN_EDGE_ACTIVATION)
            )
            continue

        shared_chains = src_node.chains & tgt_node.chains
        if not shared_chains:
            # Cross-chain bridge edge — check both endpoints' chains
            shared_chains = src_node.chains | tgt_node.chains

        activation = MIN_EDGE_ACTIVATION

        # Check vocabulary evidence
        for chain in shared_chains:
            chain_vocab = _CHAIN_VOCAB.get(chain, frozenset())
            if hits_lower & chain_vocab:
                activation = 1.0
                break

        # If no vocab match, check lens scores
        if activation < 1.0:
            for chain in shared_chains:
                lens_id = _CHAIN_LENS.get(chain, "")
                score = scores.get(lens_id, 0.0)
                if score > 0.5:
                    activation = max(activation, score)

        result[(edge.source, edge.target, edge.edge_type.value)] = (
            DynamicEdge.from_edge(edge, activation)
        )

    return result


def predict_neighbors_dynamic(
    dag: "CascadeDAG",
    detected: FrozenSet[str],
    lens_scores: Optional[Dict[str, float]] = None,
    max_hops: int = 2,
) -> NeighborhoodPrediction:
    """
    T8 enhancement: Content-aware cascade neighborliness prediction.

    Phase 7 (BS-20): Modulates T8 predictions by content evidence.
    If a neighbor's associated lens scored high → increase prediction
    confidence.  If low → decrease confidence.

    The base prediction is delegated to CascadeDAG.predict_neighbors().
    This function then adjusts confidence using lens_scores.

    Confidence adjustment:
        For each predicted node:
          - Find its chains → find associated lens → get score
          - If lens_score > 0.5 → boost_factor = 1.0 + 0.2 * lens_score
          - If lens_score ≤ 0.5 → dampen_factor = 0.5 + lens_score
          - Average adjusted confidence across all predictions
    """
    scores = lens_scores or {}

    # Delegate to the base T8 prediction
    base_prediction = dag.predict_neighbors(detected, max_hops=max_hops)

    if not base_prediction.predicted or not scores:
        return base_prediction

    # Adjust confidence per predicted node, then average
    adjusted_confidences: List[float] = []

    for nid in base_prediction.predicted:
        node = dag.get_node(nid)
        if node is None:
            adjusted_confidences.append(base_prediction.confidence)
            continue

        # Find the best lens score for this node's chains
        best_score = 0.0
        for chain in node.chains:
            lens_id = _CHAIN_LENS.get(chain, "")
            s = scores.get(lens_id, 0.0)
            if s > best_score:
                best_score = s

        # Modulate base confidence
        base_conf = base_prediction.confidence
        if best_score > 0.5:
            # Boost: evidence supports this neighbor
            factor = 1.0 + 0.2 * best_score
            adj = min(base_conf * factor, MAX_STRUCTURAL_COMPLETENESS)
        else:
            # Dampen: weak or no evidence
            factor = 0.5 + best_score
            adj = base_conf * factor

        adjusted_confidences.append(adj)

    avg_adjusted = (
        sum(adjusted_confidences) / len(adjusted_confidences)
        if adjusted_confidences
        else base_prediction.confidence
    )
    avg_adjusted = min(avg_adjusted, MAX_STRUCTURAL_COMPLETENESS)

    return NeighborhoodPrediction(
        detected=base_prediction.detected,
        predicted=base_prediction.predicted,
        confidence=avg_adjusted,
        chain_coverage=base_prediction.chain_coverage,
    )


# ========================================================================
# CascadeDAG — The Core Engine
# ========================================================================

class CascadeDAG:
    """
    A typed Directed Acyclic Graph implementing AX23 (Generative Cascade).

    Three-phase actualization (AX2→AX3→AX4) manifest in this class:
      İlim (distinction):  _nodes dict distinguishes each element
      İrade (selection):   add_edge selects specific typed morphisms
      Kudret (actualization): graph algorithms actualize structural knowledge

    Structural pattern: this class is a TRANSPARENT VESSEL (AX27) —
    it mediates cascade analysis but does not originate the cascade.
    The cascade exists in DUSUN.md; this engine makes it computable.

    Self-referential property: the CascadeDAG can load the framework's
    own inference graph (§IG.2), making the engine analyze ITSELF — the
    framework's own AX23 applied reflexively.
    """

    def __init__(self) -> None:
        self._nodes: Dict[str, CascadeNode] = {}
        self._edges: List[CascadeEdge] = []
        # Adjacency structures (rebuilt on mutation)
        self._adj: Dict[str, List[CascadeEdge]] = defaultdict(list)   # outgoing
        self._radj: Dict[str, List[CascadeEdge]] = defaultdict(list)  # incoming
        self._dirty: bool = False  # topology cache invalidation flag
        # Cached computations (lazy, invalidated on mutation)
        self._topo_cache: Optional[List[str]] = None
        self._depth_cache: Optional[Dict[str, int]] = None

    # ----------------------------------------------------------------
    # Mutation: İrade phase — specifying what enters the graph
    # ----------------------------------------------------------------

    def add_node(self, node: CascadeNode) -> None:
        """
        Register a node.  Duplicate IDs are silently updated.

        KV₁ check: every node has a classification (NodeKind),
        which is the kaskad analogue of harfî/ismî classification.
        """
        self._nodes[node.id] = node
        self._invalidate()

    def add_edge(self, edge: CascadeEdge) -> None:
        """
        Register a typed edge.

        Pre-conditions:
          - Both source and target must exist (fail loudly otherwise).
          - Adding the edge must not create a cycle (AX23: DAG property).

        §IG.1: each edge has exactly one EdgeType.
        """
        if edge.source not in self._nodes:
            raise ValueError(
                f"Source node '{edge.source}' not in graph. "
                f"AX27: causes cannot function severed from context."
            )
        if edge.target not in self._nodes:
            raise ValueError(
                f"Target node '{edge.target}' not in graph. "
                f"AX27: target must exist before connection."
            )
        # Check acyclicity: would this edge create a cycle?
        if self._would_create_cycle(edge.source, edge.target):
            raise ValueError(
                f"Edge {edge.source}→{edge.target} would create a cycle. "
                f"AX23: cascade is a DAG — cycles violate transitivity."
            )
        self._edges.append(edge)
        self._adj[edge.source].append(edge)
        self._radj[edge.target].append(edge)
        self._invalidate()

    def _invalidate(self) -> None:
        """Invalidate cached computations on mutation."""
        self._topo_cache = None
        self._depth_cache = None
        self._dirty = True

    # ----------------------------------------------------------------
    # Acyclicity guard (AX23: DAG property)
    # ----------------------------------------------------------------

    def _would_create_cycle(self, source: str, target: str) -> bool:
        """
        Check if adding source→target would create a cycle.

        Uses BFS from target in forward direction — if source is reachable
        from target, adding the edge would create a cycle.

        This IS the formal check for AX23's DAG constraint.
        """
        if source == target:
            return True
        visited: Set[str] = set()
        queue: deque[str] = deque([target])
        while queue:
            current = queue.popleft()
            if current == source:
                return True
            if current in visited:
                continue
            visited.add(current)
            for edge in self._adj.get(current, []):
                if edge.target not in visited:
                    queue.append(edge.target)
        return False

    # ----------------------------------------------------------------
    # Query: read-only structural access
    # ----------------------------------------------------------------

    @property
    def node_count(self) -> int:
        return len(self._nodes)

    @property
    def edge_count(self) -> int:
        return len(self._edges)

    def get_node(self, node_id: str) -> Optional[CascadeNode]:
        return self._nodes.get(node_id)

    def get_nodes(self) -> List[CascadeNode]:
        return list(self._nodes.values())

    def get_edges(self) -> List[CascadeEdge]:
        return list(self._edges)

    def get_edges_by_type(self, edge_type: EdgeType) -> List[CascadeEdge]:
        """Filter edges by morphism type (§IG.1)."""
        return [e for e in self._edges if e.edge_type == edge_type]

    def successors(self, node_id: str) -> List[str]:
        """Direct downstream neighbors (forward cascade)."""
        return [e.target for e in self._adj.get(node_id, [])]

    def predecessors(self, node_id: str) -> List[str]:
        """Direct upstream neighbors (reverse cascade)."""
        return [e.source for e in self._radj.get(node_id, [])]

    def in_degree(self, node_id: str) -> int:
        return len(self._radj.get(node_id, []))

    def out_degree(self, node_id: str) -> int:
        return len(self._adj.get(node_id, []))

    def nodes_by_kind(self, kind: NodeKind) -> List[CascadeNode]:
        return [n for n in self._nodes.values() if n.kind == kind]

    def nodes_in_chain(self, chain: ChainId) -> List[CascadeNode]:
        return [
            n for n in self._nodes.values()
            if chain in n.chains
        ]

    def edges_in_chain(self, chain: ChainId) -> List[CascadeEdge]:
        """
        Return edges whose source AND target are both in the given chain.
        """
        chain_ids = {n.id for n in self.nodes_in_chain(chain)}
        return [
            e for e in self._edges
            if e.source in chain_ids and e.target in chain_ids
        ]

    # ----------------------------------------------------------------
    # Topological Sort — İlim phase (distinction → ordering)
    # ----------------------------------------------------------------

    def topological_sort(self) -> List[str]:
        """
        Kahn's algorithm for topological ordering.

        AX23: because the graph is a DAG, a topological order exists.
        This ordering respects the cascade: if kaskad(a,b) then a
        appears before b.  The ordering is the distinction of
        structural priority — İlim in action.

        Returns list of node IDs in topological order.
        """
        if self._topo_cache is not None:
            return list(self._topo_cache)

        in_deg: Dict[str, int] = {nid: 0 for nid in self._nodes}
        for edge in self._edges:
            in_deg[edge.target] += 1

        queue: deque[str] = deque(
            nid for nid, d in sorted(in_deg.items()) if d == 0
        )
        result: List[str] = []

        while queue:
            current = queue.popleft()
            result.append(current)
            for edge in self._adj.get(current, []):
                in_deg[edge.target] -= 1
                if in_deg[edge.target] == 0:
                    queue.append(edge.target)

        if len(result) != len(self._nodes):
            # Should never happen if add_edge guards acyclicity,
            # but defense-in-depth (measure the wound — M2).
            raise RuntimeError(
                "Topological sort incomplete — possible cycle detected. "
                "This violates AX23."
            )

        self._topo_cache = result
        return list(result)

    # ----------------------------------------------------------------
    # Reachability — the transitive closure of kaskad
    # ----------------------------------------------------------------

    def reachable_from(self, node_id: str) -> FrozenSet[str]:
        """
        All nodes reachable from node_id via forward cascade edges.

        AX23: kaskad(a,b) ∧ kaskad(b,c) → kaskad(a,c)
        This computes the full transitive closure from a single source.
        """
        visited: Set[str] = set()
        queue: deque[str] = deque([node_id])
        while queue:
            current = queue.popleft()
            if current in visited:
                continue
            visited.add(current)
            for edge in self._adj.get(current, []):
                if edge.target not in visited:
                    queue.append(edge.target)
        visited.discard(node_id)  # exclude the starting node
        return frozenset(visited)

    def reachable_to(self, node_id: str) -> FrozenSet[str]:
        """
        All nodes from which node_id is reachable (reverse transitive closure).
        """
        visited: Set[str] = set()
        queue: deque[str] = deque([node_id])
        while queue:
            current = queue.popleft()
            if current in visited:
                continue
            visited.add(current)
            for edge in self._radj.get(current, []):
                if edge.source not in visited:
                    queue.append(edge.source)
        visited.discard(node_id)
        return frozenset(visited)

    def is_reachable(self, source: str, target: str) -> bool:
        """Check if target is reachable from source via cascade edges."""
        return target in self.reachable_from(source)

    # ----------------------------------------------------------------
    # Depth computation — the cascade's vertical extent
    # ----------------------------------------------------------------

    def compute_depths(self) -> Dict[str, int]:
        """
        Compute the depth (longest path from any root) for each node.

        Roots (in-degree 0) have depth 0.  The maximum depth is the
        cascade's 'height' — measuring the longest generative chain.

        Analogous to the ontological stack's ∞→1001→7→1 funnel:
        depth measures how many generative steps separate a node
        from the foundational axioms.
        """
        if self._depth_cache is not None:
            return dict(self._depth_cache)

        topo = self.topological_sort()
        depths: Dict[str, int] = {nid: 0 for nid in self._nodes}

        for nid in topo:
            for edge in self._adj.get(nid, []):
                if depths[edge.target] < depths[nid] + 1:
                    depths[edge.target] = depths[nid] + 1

        self._depth_cache = depths
        return dict(depths)

    def max_depth(self) -> int:
        """Maximum depth in the cascade (longest generative path)."""
        depths = self.compute_depths()
        return max(depths.values()) if depths else 0

    def critical_path(self) -> List[str]:
        """
        The longest path in the DAG — the 'backbone' of the cascade.

        This is the generative spine: the maximal chain of motivations
        from root to final output.  In the beauty cascade, this is the
        full 9-level path from Cemal_Kemal to Tanzim_Takdir.
        """
        depths = self.compute_depths()
        if not depths:
            return []

        # Start from the deepest node
        max_d = max(depths.values())
        end_nodes = [nid for nid, d in depths.items() if d == max_d]
        # Take the first (deterministic via sorted topo order)
        current = sorted(end_nodes)[0]

        path = [current]
        while depths[current] > 0:
            # Walk backward: choose predecessor with depth = current - 1
            preds = self.predecessors(current)
            upstream = [p for p in preds if depths.get(p, -1) == depths[current] - 1]
            if not upstream:
                break
            current = sorted(upstream)[0]
            path.append(current)

        path.reverse()
        return path

    # ----------------------------------------------------------------
    # Source & Sink detection
    # ----------------------------------------------------------------

    def source_nodes(self) -> FrozenSet[str]:
        """Nodes with in-degree 0 — cascade origins (axioms, şuûnât)."""
        return frozenset(
            nid for nid in self._nodes
            if self.in_degree(nid) == 0
        )

    def sink_nodes(self) -> FrozenSet[str]:
        """Nodes with out-degree 0 — cascade terminals (output rules)."""
        return frozenset(
            nid for nid in self._nodes
            if self.out_degree(nid) == 0
        )

    # ----------------------------------------------------------------
    # T8 — Cascade Neighborliness Prediction
    # ----------------------------------------------------------------

    def predict_neighbors(
        self,
        detected: Iterable[str],
        max_hops: int = 2,
    ) -> NeighborhoodPrediction:
        """
        T8: Cascade Neighborliness prediction.

        Given a set of detected nodes, predict which cascade neighbors
        should be predictably detectable.

        ∀n₁, n₂: kaskad(n₁, n₂) → (Detectable(n₁) → PredictablyDetectable(n₂))

        Confidence decays with hop distance (unreachable supremum — T6).
        Multi-hop predictions have strictly lower confidence.

        Parameters:
            detected:  Set of node IDs that have been observed/detected
            max_hops:  Maximum cascade distance for prediction (default: 2)

        Returns:
            NeighborhoodPrediction with predicted nodes and confidence
        """
        detected_set = frozenset(detected)
        # Validate all detected nodes exist
        for nid in detected_set:
            if nid not in self._nodes:
                raise ValueError(f"Detected node '{nid}' not in graph.")

        predicted: Set[str] = set()
        total_confidence = 0.0
        hop_count = 0

        for hop in range(1, max_hops + 1):
            frontier: Set[str] = set()
            source_set = detected_set if hop == 1 else frozenset(predicted)

            for nid in source_set:
                # Both forward and backward neighbors are predictable
                for edge in self._adj.get(nid, []):
                    if edge.target not in detected_set:
                        frontier.add(edge.target)
                for edge in self._radj.get(nid, []):
                    if edge.source not in detected_set:
                        frontier.add(edge.source)

            new_predictions = frontier - predicted - detected_set
            if new_predictions:
                hop_confidence = max(
                    0.0,
                    T8_BASE_CONFIDENCE - (hop - 1) * T8_HOP_DECAY
                )
                total_confidence += hop_confidence * len(new_predictions)
                hop_count += len(new_predictions)
                predicted |= new_predictions

        # Average confidence across all predictions, bounded < 1.0
        avg_confidence = (
            min(total_confidence / hop_count, MAX_STRUCTURAL_COMPLETENESS)
            if hop_count > 0
            else 0.0
        )

        # Per-chain coverage
        chain_coverage: Dict[ChainId, float] = {}
        for chain in ChainId:
            chain_nodes = {n.id for n in self.nodes_in_chain(chain)}
            if not chain_nodes:
                continue
            covered = (detected_set | predicted) & chain_nodes
            chain_coverage[chain] = len(covered) / len(chain_nodes)

        return NeighborhoodPrediction(
            detected=detected_set,
            predicted=frozenset(predicted),
            confidence=avg_confidence,
            chain_coverage=chain_coverage,
        )

    # ----------------------------------------------------------------
    # AX63 — Tesanüd & İnfirâd Analysis
    # ----------------------------------------------------------------

    def analyze_tesanud(self) -> List[TesanudResult]:
        """
        AX63: Tesanüd–İnfirâd asymmetry.

        For each node, determine:
          - How many INDEPENDENT chains converge on it (KV₇)
          - If ≥2 → tesanüd (super-additive composition)
          - If 1  → infirâdî (isolated finding)

        Hub nodes with high tesanüd are the most structurally
        significant — they are where independent evidence compounds.

        §IG.3: KV₄ is the most connected constraint (3 chains).
        """
        results: List[TesanudResult] = []

        for nid, node in self._nodes.items():
            converging = node.chains
            count = len(converging)

            if count >= MIN_TESANUD_CHAINS:
                mode = "super_additive"
                is_tes = True
                is_inf = False
            elif count == 1:
                mode = "isolated"
                is_tes = False
                is_inf = True
            else:
                mode = "n/a"
                is_tes = False
                is_inf = False

            results.append(TesanudResult(
                node_id=nid,
                converging_chains=converging,
                chain_count=count,
                is_tesanud=is_tes,
                is_infiradi=is_inf,
                composition_mode=mode,
            ))

        # Sort: tesanüd nodes first, by chain count descending
        results.sort(key=lambda r: (-r.chain_count, r.node_id))
        return results

    # ----------------------------------------------------------------
    # §IG.3 — Hub & Bridge Analysis
    # ----------------------------------------------------------------

    def analyze_hubs(self, min_in_degree: int = 2) -> List[HubAnalysis]:
        """
        §IG.3: Hub nodes — highest in-degree across chains.

        Hub nodes are the structural anchors where multiple inference
        paths converge.  They are:
          - KV₄  (C3, C4, C7) — most connected constraint
          - OR-2 (C4, C5) — epistemic ceiling
          - T6   (C3, C7) — convergence theorem
          - AX58 (C4, C5) — şuhud–istidlal gap
        """
        hubs: List[HubAnalysis] = []

        for nid, node in self._nodes.items():
            indeg = self.in_degree(nid)
            if indeg < min_in_degree:
                continue

            outdeg = self.out_degree(nid)
            chain_count = len(node.chains)
            is_bridge = chain_count >= 2

            hubs.append(HubAnalysis(
                node_id=nid,
                in_degree=indeg,
                out_degree=outdeg,
                chain_count=chain_count,
                chains=node.chains,
                is_bridge=is_bridge,
            ))

        hubs.sort(key=lambda h: (-h.in_degree, -h.chain_count, h.node_id))
        return hubs

    def find_bridges(self) -> List[str]:
        """
        §IG.3: Bridge nodes — shared between ≥2 chains.

        Cross-chain bridges are structurally significant because
        they enable tesanüd (super-additive convergence, AX63).
        The fact that the SAME node is reached independently by
        multiple chains STRENGTHENS the constraint (KV₇ satisfied).
        """
        return sorted(
            nid for nid, node in self._nodes.items()
            if len(node.chains) >= 2
        )

    # ----------------------------------------------------------------
    # Chain-level integrity analysis
    # ----------------------------------------------------------------

    def analyze_chain(self, chain: ChainId) -> ChainIntegrity:
        """
        Validate a single inference chain's structural integrity.

        Checks:
          1. Acyclicity (always true if add_edge guards work)
          2. Connectivity (weak, within chain subgraph)
          3. Node/edge counts
          4. Source/sink identification
          5. Maximum depth
        """
        chain_nodes = {n.id for n in self.nodes_in_chain(chain)}
        chain_edges = self.edges_in_chain(chain)

        if not chain_nodes:
            return ChainIntegrity(
                chain_id=chain,
                is_acyclic=True,
                is_connected=True,
                node_count=0,
                edge_count=0,
                source_nodes=frozenset(),
                sink_nodes=frozenset(),
                max_depth=0,
            )

        # Build sub-adjacency for the chain
        sub_adj: Dict[str, Set[str]] = defaultdict(set)
        sub_radj: Dict[str, Set[str]] = defaultdict(set)
        for e in chain_edges:
            sub_adj[e.source].add(e.target)
            sub_radj[e.target].add(e.source)

        # Sources and sinks within the chain
        sources = frozenset(
            nid for nid in chain_nodes
            if not sub_radj.get(nid)
        )
        sinks = frozenset(
            nid for nid in chain_nodes
            if not sub_adj.get(nid)
        )

        # Weak connectivity check
        undirected: Dict[str, Set[str]] = defaultdict(set)
        for e in chain_edges:
            undirected[e.source].add(e.target)
            undirected[e.target].add(e.source)

        if chain_nodes:
            start = next(iter(chain_nodes))
            visited: Set[str] = set()
            queue: deque[str] = deque([start])
            while queue:
                c = queue.popleft()
                if c in visited:
                    continue
                visited.add(c)
                for nb in undirected.get(c, set()):
                    if nb not in visited and nb in chain_nodes:
                        queue.append(nb)
            is_connected = visited >= chain_nodes
        else:
            is_connected = True

        # Depth within chain subgraph (longest path via BFS-based DP)
        # Topological order within chain
        chain_indeg: Dict[str, int] = {nid: 0 for nid in chain_nodes}
        for e in chain_edges:
            chain_indeg[e.target] += 1

        topo_q: deque[str] = deque(
            nid for nid in sorted(chain_nodes) if chain_indeg[nid] == 0
        )
        chain_topo: List[str] = []
        while topo_q:
            c = topo_q.popleft()
            chain_topo.append(c)
            for nb in sub_adj.get(c, set()):
                chain_indeg[nb] -= 1
                if chain_indeg[nb] == 0:
                    topo_q.append(nb)

        depths: Dict[str, int] = {nid: 0 for nid in chain_nodes}
        for nid in chain_topo:
            for nb in sub_adj.get(nid, set()):
                if depths[nb] < depths[nid] + 1:
                    depths[nb] = depths[nid] + 1

        chain_max_depth = max(depths.values()) if depths else 0

        return ChainIntegrity(
            chain_id=chain,
            is_acyclic=len(chain_topo) == len(chain_nodes),
            is_connected=is_connected,
            node_count=len(chain_nodes),
            edge_count=len(chain_edges),
            source_nodes=sources,
            sink_nodes=sinks,
            max_depth=chain_max_depth,
        )

    # ----------------------------------------------------------------
    # Full Cascade Report — AX57 Transparency
    # ----------------------------------------------------------------

    def generate_report(self) -> CascadeReport:
        """
        Generate a full cascade analysis report.

        AX57: Every output MUST include a transparency statement.
        This report is the cascade engine's equivalent of bileshke's
        QualityReport — full structural disclosure.

        KV₄: structural_completeness is always < 1.0 because the
        cascade analysis is a map that participates in the territory
        but is never identical to it.
        """
        topo = self.topological_sort()
        depths = self.compute_depths()
        max_d = max(depths.values()) if depths else 0

        # Chain integrity for all chains that have nodes
        chain_integrity: Dict[ChainId, ChainIntegrity] = {}
        for chain in ChainId:
            ci = self.analyze_chain(chain)
            if ci.node_count > 0:
                chain_integrity[chain] = ci

        hubs = self.analyze_hubs()
        bridges = self.find_bridges()
        tesanud = self.analyze_tesanud()

        # Structural completeness: ratio of analyzed structure
        # over theoretically complete structure.
        # T17: max = 6/7, then KV₄: never reach 1.0
        # Use chain coverage as proxy:
        active_chains = len(chain_integrity)
        total_chains = len(ChainId)
        raw_completeness = active_chains / total_chains if total_chains > 0 else 0.0
        # Apply T17 ceiling (6/7 ≈ 0.857) then KV₄ hard cap
        structural_completeness = min(
            raw_completeness * (6.0 / 7.0),
            MAX_STRUCTURAL_COMPLETENESS,
        )

        return CascadeReport(
            total_nodes=self.node_count,
            total_edges=self.edge_count,
            chain_count=active_chains,
            chain_integrity=chain_integrity,
            hub_nodes=hubs,
            bridge_nodes=bridges,
            tesanud_nodes=tesanud,
            topological_order=topo,
            max_depth=max_d,
            structural_completeness=structural_completeness,
        )

    # ----------------------------------------------------------------
    # Self-diagnostic — the engine validates ITSELF (AX23 reflexive)
    # ----------------------------------------------------------------

    def self_diagnostic(self) -> Dict[str, object]:
        """
        Self-diagnostic: the cascade engine validates its own integrity.

        This is the most interesting function — it is AX23 applied
        reflexively.  The framework says Names form a DAG and motivate
        each other.  The framework's OWN elements form a DAG.
        And now the engine that implements cascades runs a cascade
        analysis on ITSELF.

        Returns a diagnostic dict with:
          - is_dag: True if the graph is acyclic
          - all_nodes_classified: True if every node has a NodeKind (KV₁)
          - all_nodes_grounded: True if every non-source node has predecessors
          - tesanud_count: number of super-additive convergence points
          - bridge_count: number of cross-chain bridges
          - max_depth: longest generative chain
          - kv4_satisfied: structural_completeness < 1.0
          - connectivity: ratio of weakly-connected components
        """
        report = self.generate_report()

        # KV₁: every node classified?
        all_classified = all(
            node.kind is not None for node in self._nodes.values()
        )

        # Every non-source node has at least one predecessor?
        sources = self.source_nodes()
        all_grounded = all(
            self.in_degree(nid) > 0
            for nid in self._nodes
            if nid not in sources
        )

        # Tesanüd count
        tesanud_count = sum(
            1 for t in report.tesanud_nodes if t.is_tesanud
        )

        # Connectivity: count weakly-connected components
        if not self._nodes:
            component_count = 0
        else:
            visited: Set[str] = set()
            component_count = 0
            for nid in self._nodes:
                if nid in visited:
                    continue
                component_count += 1
                queue: deque[str] = deque([nid])
                while queue:
                    c = queue.popleft()
                    if c in visited:
                        continue
                    visited.add(c)
                    for e in self._adj.get(c, []):
                        queue.append(e.target)
                    for e in self._radj.get(c, []):
                        queue.append(e.source)

        connectivity = (
            1.0 / component_count if component_count > 0 else 0.0
        )

        return {
            "is_dag": True,  # guaranteed by add_edge guard
            "all_nodes_classified": all_classified,
            "all_nodes_grounded": all_grounded,
            "tesanud_count": tesanud_count,
            "bridge_count": len(report.bridge_nodes),
            "max_depth": report.max_depth,
            "kv4_satisfied": report.structural_completeness < 1.0,
            "structural_completeness": report.structural_completeness,
            "component_count": component_count,
            "connectivity": min(connectivity, MAX_STRUCTURAL_COMPLETENESS),
        }

    # ----------------------------------------------------------------
    # Subgraph extraction
    # ----------------------------------------------------------------

    def subgraph(self, node_ids: Iterable[str]) -> "CascadeDAG":
        """
        Extract a subgraph containing only the specified nodes
        and edges between them.

        Useful for extracting a single chain's subgraph or
        a neighborhood around a detected set.
        """
        nids = frozenset(node_ids)
        sub = CascadeDAG()
        for nid in nids:
            node = self._nodes.get(nid)
            if node is not None:
                sub.add_node(node)
        for edge in self._edges:
            if edge.source in nids and edge.target in nids:
                sub.add_edge(edge)
        return sub

    # ----------------------------------------------------------------
    # Cascade propagation — the full T7 + T8 engine
    # ----------------------------------------------------------------

    def propagate(
        self,
        seeds: Iterable[str],
        max_hops: int = -1,
    ) -> List[Tuple[str, int]]:
        """
        Full cascade propagation from seed nodes.

        T7: Creation exists because Beauty desires mirrors. The cascade
        propagates from root (Beauty/Cemal) outward. This function
        generalizes that: given ANY set of seed nodes, propagate
        the cascade forward, returning (node_id, distance) pairs.

        If max_hops == -1, propagate to full reachability.

        Returns:
            List of (node_id, hop_distance) sorted by distance, then id.
        """
        seed_set = frozenset(seeds)
        visited: Dict[str, int] = {}
        queue: deque[Tuple[str, int]] = deque(
            (s, 0) for s in sorted(seed_set) if s in self._nodes
        )

        while queue:
            nid, dist = queue.popleft()
            if nid in visited:
                continue
            if max_hops >= 0 and dist > max_hops:
                continue
            visited[nid] = dist
            for edge in self._adj.get(nid, []):
                if edge.target not in visited:
                    queue.append((edge.target, dist + 1))

        result = sorted(visited.items(), key=lambda x: (x[1], x[0]))
        return result

    # ----------------------------------------------------------------
    # Dunder protocols
    # ----------------------------------------------------------------

    def __len__(self) -> int:
        return self.node_count

    def __contains__(self, node_id: str) -> bool:
        return node_id in self._nodes

    def __repr__(self) -> str:
        return (
            f"CascadeDAG(nodes={self.node_count}, "
            f"edges={self.edge_count})"
        )
