"""
Unified Graph Extractor

Single-pass extraction that captures all memory types in one LLM call:
- Entities (WHO, WHAT)
- Relationships (CONNECTIONS)
- Decisions (WHY)
- Next Actions (NEXT-STEPS)

Design Goals:
- Reduce from 6 LLM calls to 1-2 calls
- Target: 50-80x overhead vs 220x current
- Maintain same memory quality

Token Optimization Strategies:
1. Single comprehensive extraction prompt
2. Compact JSON schema (short keys)
3. Reference-based relationships (use IDs, not full text)
4. Delta extraction for multi-turn (only process new messages)
"""

from __future__ import annotations

import base64
import json
import hashlib
import logging
from datetime import datetime, timezone
from typing import Optional, Protocol
from dataclasses import dataclass, field
from enum import Enum

from pydantic import BaseModel, Field


def _utc_now() -> datetime:
    """Return current UTC time (timezone-aware)."""
    return datetime.now(timezone.utc)


logger = logging.getLogger(__name__)


# =============================================================================
# Compact Data Models (Optimized for Token Efficiency)
# =============================================================================

class NodeType(str, Enum):
    """Node types in the unified graph.

    Issue #108 — Grammar-aware trichotomy:
      * **Entities** (NPs):  PERSON, THING, CONCEPT — what EXISTS
      * **Events** (VPs):    GOAL, DECISION, ACTION   — what HAPPENS / is CHOSEN
      * **Propositions** (Clauses): FACT              — what is TRUE / ASSERTED
    """
    PERSON = "P"      # WHO - proper/common noun phrases (Named Entity)
    THING = "T"       # WHAT - concrete noun phrases (Referential Entity)
    CONCEPT = "C"     # WHAT - abstract noun phrases (Abstract Entity)
    GOAL = "G"        # VP/Infinitive - objectives, targets (Event/Intent)
    DECISION = "D"    # VP/Imperative - choices made with rationale (Event/Choice)
    ACTION = "A"      # VP/Nominalized - tasks, next steps (Event/Task)
    FACT = "F"        # Declarative clause - quantitative data, assertions (Proposition)


class DeduplicationStrategy(str, Enum):
    """Strategy for detecting duplicate nodes."""
    EXACT_ONLY = "exact"     # Only exact label matches (case-insensitive)
    SEMANTIC = "semantic"    # Use embedding similarity only
    HYBRID = "hybrid"        # Exact first, then semantic (default)
    NONE = "none"            # No deduplication


@dataclass
class DeduplicationStats:
    """Track deduplication statistics for debugging."""
    nodes_added: int = 0
    nodes_merged: int = 0
    exact_matches: int = 0
    semantic_matches: int = 0
    
    @property
    def total_operations(self) -> int:
        return self.nodes_added + self.nodes_merged
    
    @property
    def dedup_ratio(self) -> float:
        """Ratio of merged nodes to total operations."""
        if self.total_operations == 0:
            return 0.0
        return self.nodes_merged / self.total_operations
    
    def reset(self) -> None:
        """Reset all counters."""
        self.nodes_added = 0
        self.nodes_merged = 0
        self.exact_matches = 0
        self.semantic_matches = 0


class CompactionPolicy(str, Enum):
    """Controls how aggressively summarize_compact() merges nodes."""
    CONSERVATIVE = "conservative"
    MODERATE = "moderate"
    AGGRESSIVE = "aggressive"


COMPACTION_SCORE_THRESHOLDS: dict[CompactionPolicy, float] = {
    CompactionPolicy.CONSERVATIVE: 5.0,
    CompactionPolicy.MODERATE: 8.0,
    CompactionPolicy.AGGRESSIVE: 12.0,
}


@dataclass
class CompactionEvent:
    """Observability record for a single compaction run."""
    nodes_before: int = 0
    nodes_after: int = 0
    edges_before: int = 0
    edges_after: int = 0
    nodes_evicted: int = 0
    summary_nodes_created: int = 0
    policy: str = "moderate"
    trigger: str = "manual"

    @property
    def nodes_reduced(self) -> int:
        return self.nodes_before - self.nodes_after

    def to_dict(self) -> dict:
        return {
            "nodes_before": self.nodes_before,
            "nodes_after": self.nodes_after,
            "edges_before": self.edges_before,
            "edges_after": self.edges_after,
            "nodes_evicted": self.nodes_evicted,
            "nodes_reduced": self.nodes_reduced,
            "summary_nodes_created": self.summary_nodes_created,
            "policy": self.policy,
            "trigger": self.trigger,
        }


class EdgeType(str, Enum):
    """Relationship types (compact)."""
    USES = "uses"           # Person/Goal uses Thing
    WANTS = "wants"         # Person wants Goal
    CHOSE = "chose"         # Person/Goal chose Decision
    OVER = "over"           # Decision over Alternative (with pros/cons attrs)
    BECAUSE = "because"     # Decision because Reason (with source/confidence attrs)
    ENABLES = "enables"     # Thing/Decision enables Goal
    BLOCKS = "blocks"       # Thing blocks Goal
    NEXT = "next"           # Current state leads to Action
    RELATES = "relates"     # General relationship
    PART_OF = "part_of"     # Hierarchical containment
    # NEW in Unified Deep v2
    DEPENDS_ON = "depends_on"   # Task/Goal A requires B to complete first
    CONFLICTS = "conflicts"     # Goal X conflicts with Goal Y
    SUPERSEDES = "supersedes"   # Decision D2 replaces/updates D1


@dataclass
class GraphNode:
    """
    Compact graph node.
    
    Memory optimization: Use short field names and IDs.
    
    Embedding storage (Issue #50):
    - Stored as Float16 binary (bytes) for 8x compression
    - Serialized to base64 in JSON for persistence
    - Quality preserved: 0% loss in search accuracy
    """
    id: str                    # Short ID: "P1", "T2", "D3"
    type: NodeType
    label: str                 # Short label: "PostgreSQL", "fraud detection"
    attrs: dict = field(default_factory=dict)  # Optional attributes
    embedding: Optional[bytes] = None  # Float16 binary embedding (384 dim = 768 bytes)
    
    def to_dict(self) -> dict:
        d = {"id": self.id, "t": self.type.value, "l": self.label}
        if self.attrs:
            d["a"] = self.attrs
        if self.embedding:
            # Store as base64-encoded Float16 binary (compact)
            d["e"] = base64.b64encode(self.embedding).decode('ascii')
        return d
    
    @classmethod
    def from_dict(cls, d: dict) -> "GraphNode":
        embedding = None
        if "e" in d:
            embedding = base64.b64decode(d["e"])
        return cls(
            id=d["id"],
            type=NodeType(d["t"]),
            label=d["l"],
            attrs=d.get("a", {}),
            embedding=embedding
        )


@dataclass  
class GraphEdge:
    """
    Compact graph edge.
    """
    src: str          # Source node ID
    dst: str          # Destination node ID
    rel: EdgeType     # Relationship type
    attrs: dict = field(default_factory=dict)
    
    def to_dict(self) -> dict:
        d = {"s": self.src, "d": self.dst, "r": self.rel.value}
        if self.attrs:
            d["a"] = self.attrs
        return d
    
    @classmethod
    def from_dict(cls, d: dict) -> "GraphEdge":
        return cls(
            src=d["s"],
            dst=d["d"],
            rel=EdgeType(d["r"]),
            attrs=d.get("a", {})
        )


# Issue #123: Per-type dedup thresholds — sensible defaults per NodeType.
# Entities (NPs) are more liberally deduped; events/propositions need stricter matching.
DEFAULT_TYPE_THRESHOLDS: dict[NodeType, float] = {
    NodeType.PERSON: 0.75,
    NodeType.THING: 0.70,
    NodeType.CONCEPT: 0.70,
    NodeType.GOAL: 0.85,
    NodeType.DECISION: 0.90,
    NodeType.ACTION: 0.80,
    NodeType.FACT: 0.85,
}


@dataclass
class UnifiedGraph:
    """
    Compact unified graph containing all memory types.
    
    This single structure replaces:
    - IntentGraph (goals, tasks)
    - DecisionTrail (decisions, alternatives, rationale)
    - Facts (entities, relationships)
    - NextActions (follow-ups)
    
    Deduplication (Issue #52, Issue #123):
    - Hybrid strategy: exact match first, then semantic similarity
    - Per-type thresholds (Issue #123): different node types use different
      similarity thresholds for semantic dedup matching
    - Configurable global fallback threshold (default 0.80)
    - Stats tracking for debugging
    """
    nodes: dict[str, GraphNode] = field(default_factory=dict)
    edges: list[GraphEdge] = field(default_factory=list)
    metadata: dict = field(default_factory=dict)
    
    # Deduplication configuration
    _dedup_strategy: DeduplicationStrategy = field(default=DeduplicationStrategy.HYBRID)
    _similarity_threshold: float = field(default=0.80)
    # Issue #123: Per-type thresholds override _similarity_threshold for semantic matching
    _type_thresholds: dict[NodeType, float] = field(default_factory=lambda: dict(DEFAULT_TYPE_THRESHOLDS))
    _dedup_stats: DeduplicationStats = field(default_factory=DeduplicationStats)
    
    # Internal indexes (rebuilt on demand)
    _label_index: dict[str, list[str]] = field(default_factory=dict)  # normalized_label -> [node_ids]
    _vector_index: "VectorIndex" = field(default=None)  # Lazy-built
    _index_dirty: bool = field(default=True)  # Issue #93: dirty flag for FAISS index caching
    _embedder: "EmbeddingService" = field(default=None)  # Issue #98: cached embedding service
    
    def __post_init__(self):
        """Initialize internal indexes."""
        # Rebuild label index from existing nodes
        self._rebuild_label_index()

    def _get_threshold_for_type(self, node_type: NodeType) -> float:
        """Issue #123: Return the dedup similarity threshold for a given node type.
        
        Falls back to the global _similarity_threshold if no per-type override.
        """
        return self._type_thresholds.get(node_type, self._similarity_threshold)
    
    def _get_embedder(self) -> "EmbeddingService":
        """Return cached EmbeddingService (Issue #98)."""
        if self._embedder is None:
            from .embeddings import EmbeddingService
            self._embedder = EmbeddingService()
        return self._embedder
    
    def _rebuild_label_index(self) -> None:
        """Rebuild the label index from current nodes."""
        self._label_index = {}
        for node in self.nodes.values():
            self._index_node_label(node)
    
    def _index_node_label(self, node: GraphNode) -> None:
        """Add node to label index."""
        normalized = self._normalize_label(node.label)
        if normalized not in self._label_index:
            self._label_index[normalized] = []
        if node.id not in self._label_index[normalized]:
            self._label_index[normalized].append(node.id)
    
    @staticmethod
    def _normalize_label(label: str) -> str:
        """Normalize label for exact matching.
        
        Issue #132: Enhanced normalization to catch common LLM label variations:
        - Case + whitespace normalization
        - Strip trailing qualifiers ("database", "framework", "tool", etc.)
        - Strip leading articles ("the", "a", "an")
        - Collapse internal whitespace
        """
        s = label.lower().strip()
        # Collapse internal whitespace
        s = " ".join(s.split())
        # Strip leading articles
        for article in ("the ", "a ", "an "):
            if s.startswith(article):
                s = s[len(article):]
                break
        # Strip common trailing qualifiers that LLMs append
        _SUFFIXES = (
            " database", " db", " framework", " library", " tool",
            " platform", " service", " system", " engine", " server",
            " client", " sdk", " api", " cluster", " instance",
            " project", " app", " application",
        )
        for suffix in _SUFFIXES:
            if s.endswith(suffix) and len(s) > len(suffix) + 1:
                s = s[: -len(suffix)]
                break
        return s.strip()
    
    # ==========================================================================
    # Deduplication Methods (Issue #52)
    # ==========================================================================
    
    def find_duplicate(
        self, 
        node: GraphNode, 
        strategy: Optional[DeduplicationStrategy] = None
    ) -> Optional[str]:
        """
        Find existing duplicate node.
        
        Args:
            node: Node to check for duplicates
            strategy: Override default strategy (optional)
        
        Returns:
            node_id of existing duplicate, or None
        """
        strategy = strategy or self._dedup_strategy
        
        if strategy == DeduplicationStrategy.NONE:
            return None
        
        # Phase 1: Exact match (O(1) hash lookup)
        if strategy in (DeduplicationStrategy.EXACT_ONLY, DeduplicationStrategy.HYBRID):
            normalized = self._normalize_label(node.label)
            if normalized in self._label_index:
                for existing_id in self._label_index[normalized]:
                    existing = self.nodes.get(existing_id)
                    if existing and existing.type == node.type:
                        return existing_id
        
        # Phase 2: Semantic match (requires embeddings)
        # Issue #123: Use per-type threshold for the incoming node's type
        if strategy in (DeduplicationStrategy.SEMANTIC, DeduplicationStrategy.HYBRID):
            if node.embedding and self._vector_index:
                threshold = self._get_threshold_for_type(node.type)
                results = self._vector_index.search(node.embedding, k=3)
                for existing_id, score in results:
                    if score >= threshold:
                        existing = self.nodes.get(existing_id)
                        if existing and existing.type == node.type:
                            return existing_id
        
        return None
    
    def _merge_node(self, existing_id: str, new_node: GraphNode) -> None:
        """
        Merge new node data into existing node.
        
        - Merges attributes (new values override existing)
        - Updates embedding if new one is provided
        - Preserves original ID and type
        """
        existing = self.nodes[existing_id]
        
        # Merge attributes (new values override)
        for key, value in new_node.attrs.items():
            existing.attrs[key] = value
        
        # Update embedding if new one is provided
        if new_node.embedding:
            existing.embedding = new_node.embedding
            # FAISS doesn't support update - mark dirty for rebuild
            self._index_dirty = True  # Issue #93
    
    def _generate_all_embeddings(self, force_load: bool = False) -> None:
        """
        Batch generate embeddings for all nodes that don't have them.
        
        Issue #115: Uses batch encoding (6.4x faster than individual).
        
        Args:
            force_load: If True, load the embedding model even if not yet loaded.
                        If False (default), only generates embeddings if the model
                        is already loaded (e.g., from a prior search() call).
                        This avoids import crashes in test environments.
        """
        nodes_without = [n for n in self.nodes.values() if n.embedding is None]
        if not nodes_without:
            return
        # Skip if embedder not yet loaded and we're not forcing
        if not force_load and self._embedder is None:
            return
        try:
            embedder = self._get_embedder()
            embeddings = embedder.embed_nodes(nodes_without)
            for node, emb in zip(nodes_without, embeddings):
                node.embedding = emb
            self._index_dirty = True
            logger.debug(f"Generated embeddings for {len(nodes_without)} nodes")
        except Exception as e:
            logger.warning(f"Batch embedding generation failed: {e}")
    
    def add_node(
        self, 
        node: GraphNode, 
        deduplicate: bool = True,
        generate_embedding: bool = False
    ) -> str:
        """
        Add node with optional deduplication.
        
        Args:
            node: Node to add
            deduplicate: Whether to check for duplicates (default True)
            generate_embedding: Whether to generate embedding if missing
        
        Returns:
            Actual node ID used (may be existing duplicate's ID)
        """
        # Generate embedding if requested and missing
        if generate_embedding and node.embedding is None:
            node.embedding = self._get_embedder().embed_node(node)
        
        # Check for duplicates
        if deduplicate and self._dedup_strategy != DeduplicationStrategy.NONE:
            existing_id = self.find_duplicate(node)
            if existing_id:
                # Merge into existing node
                self._merge_node(existing_id, node)
                self._dedup_stats.nodes_merged += 1
                # Track match type
                normalized = self._normalize_label(node.label)
                if normalized in self._label_index and existing_id in self._label_index.get(normalized, []):
                    self._dedup_stats.exact_matches += 1
                else:
                    self._dedup_stats.semantic_matches += 1
                return existing_id
        
        # Add as new node
        self.nodes[node.id] = node
        self._index_node_label(node)
        self._dedup_stats.nodes_added += 1
        self._index_dirty = True  # Issue #93: mark index stale
        
        # Add to vector index if available
        if node.embedding and self._vector_index:
            self._vector_index.add(node.id, node.embedding)
        
        return node.id
    
    @property
    def dedup_stats(self) -> DeduplicationStats:
        """Get deduplication statistics."""
        return self._dedup_stats
    
    def configure_dedup(
        self,
        strategy: DeduplicationStrategy = DeduplicationStrategy.HYBRID,
        threshold: float = 0.80,
        type_thresholds: Optional[dict[NodeType, float]] = None,
    ) -> None:
        """
        Configure deduplication settings.
        
        Args:
            strategy: Deduplication strategy
            threshold: Global similarity threshold for semantic matching (0.0-1.0).
                       Used as fallback when no per-type threshold is set.
            type_thresholds: Issue #123 — Per-NodeType thresholds that override
                             the global threshold.  Pass ``{}`` to clear all
                             per-type overrides and use only the global threshold.
                             Pass ``None`` (default) to keep current per-type settings.
        """
        self._dedup_strategy = strategy
        self._similarity_threshold = threshold
        if type_thresholds is not None:
            self._type_thresholds = dict(type_thresholds)
    
    def add_edge(self, edge: GraphEdge) -> None:
        """Add edge."""
        self.edges.append(edge)
    
    def merge(self, other: "UnifiedGraph") -> "UnifiedGraph":
        """Merge another graph into this one, deduplicating nodes.
        
        Issue #115: Now uses full dedup pipeline (exact + semantic matching)
        and properly merges attributes on matched nodes.
        """
        self._index_dirty = True  # Issue #93: merge always invalidates index
        
        # Issue #115 + #132: Generate embeddings and build vector index for semantic matching
        # Issue #132: Use force_load=True so embeddings are actually generated.
        # Previously force_load=False caused silent no-op when _embedder was None,
        # making semantic dedup non-functional in real conversations.
        if (self._dedup_strategy in (DeduplicationStrategy.SEMANTIC, DeduplicationStrategy.HYBRID)
                and self.nodes):
            try:
                # Generate embeddings for existing nodes
                self._generate_all_embeddings(force_load=True)
                # Also generate for delta nodes
                other._generate_all_embeddings(force_load=True)
                self.build_vector_index()
            except Exception as e:
                logger.warning(f"Could not build vector index for merge dedup: {e}")
        
        id_mapping = {}  # Map other IDs to our IDs
        
        for node in other.nodes.values():
            # Issue #115: Use full dedup pipeline (exact + semantic)
            existing_id = self.find_duplicate(node)
            if existing_id:
                # Issue #115: Merge attributes into existing node (was silently dropped)
                self._merge_node(existing_id, node)
                id_mapping[node.id] = existing_id
                self._dedup_stats.nodes_merged += 1
                # Track match type
                normalized = self._normalize_label(node.label)
                if normalized in self._label_index and existing_id in self._label_index.get(normalized, []):
                    self._dedup_stats.exact_matches += 1
                else:
                    self._dedup_stats.semantic_matches += 1
            else:
                # New node, generate new ID
                new_id = self._next_id(node.type)
                id_mapping[node.id] = new_id
                new_node = GraphNode(
                    id=new_id,
                    type=node.type,
                    label=node.label,
                    attrs=node.attrs,
                    embedding=node.embedding  # Issue #115: preserve embedding
                )
                self.add_node(new_node, deduplicate=False)  # Already checked above
        
        # Merge edges with remapped IDs
        existing_edges = {(e.src, e.dst, e.rel) for e in self.edges}
        for edge in other.edges:
            new_src = id_mapping.get(edge.src, edge.src)
            new_dst = id_mapping.get(edge.dst, edge.dst)
            edge_key = (new_src, new_dst, edge.rel)
            if edge_key not in existing_edges:
                self.edges.append(GraphEdge(
                    src=new_src,
                    dst=new_dst,
                    rel=edge.rel,
                    attrs=edge.attrs
                ))
                existing_edges.add(edge_key)
        
        return self
    
    def _next_id(self, node_type: NodeType) -> str:
        """Generate next ID for node type."""
        prefix = node_type.value
        existing = [n.id for n in self.nodes.values() if n.id.startswith(prefix)]
        if not existing:
            return f"{prefix}1"
        max_num = max(int(nid[1:]) for nid in existing if nid[1:].isdigit())
        return f"{prefix}{max_num + 1}"
    
    # ==========================================================================
    # Graph Compaction (Issue #96)
    # ==========================================================================
    
    def importance_scores(self) -> dict[str, float]:
        """
        Compute importance score for every node.
        
        Score components:
        1. Type importance — GOAL/DECISION highest
        2. Edge degree — more connections = more important (0.5 per edge)
        3. Recency — higher turn_ref numbers = more recent (0–5 points)
        4. Attribute richness — nodes with more attrs are richer (0–2 points)
        
        Returns:
            Dict mapping node_id to importance score
        """
        # Type importance weights — GOAL and DECISION are most valuable
        type_importance: dict[NodeType, float] = {
            NodeType.GOAL: 10.0,
            NodeType.DECISION: 8.0,
            NodeType.ACTION: 6.0,
            NodeType.FACT: 5.0,       # Issue #107: quantitative data is high value
            NodeType.PERSON: 4.0,
            NodeType.THING: 3.0,
            NodeType.CONCEPT: 2.0,
        }
        # Pre-compute edge degree for each node
        degree: dict[str, int] = {}
        for edge in self.edges:
            degree[edge.src] = degree.get(edge.src, 0) + 1
            degree[edge.dst] = degree.get(edge.dst, 0) + 1
        
        # Find max turn number across all nodes for normalization
        max_turn = 0
        for node in self.nodes.values():
            turn_refs = node.attrs.get("turn_ref", [])
            if isinstance(turn_refs, str):
                turn_refs = [turn_refs]
            for ref in turn_refs:
                if isinstance(ref, str) and ref.startswith("turn_"):
                    try:
                        max_turn = max(max_turn, int(ref.split("_")[1]))
                    except (ValueError, IndexError):
                        pass
        
        scores: dict[str, float] = {}
        for node_id, node in self.nodes.items():
            score = 0.0
            
            # 1. Type importance
            score += type_importance.get(node.type, 1.0)
            
            # 2. Edge degree (0.5 per connection, capped at 5)
            score += min(degree.get(node_id, 0) * 0.5, 5.0)
            
            # 3. Recency from turn_ref (0–5 points)
            turn_refs = node.attrs.get("turn_ref", [])
            if isinstance(turn_refs, str):
                turn_refs = [turn_refs]
            node_max_turn = 0
            for ref in turn_refs:
                if isinstance(ref, str) and ref.startswith("turn_"):
                    try:
                        node_max_turn = max(node_max_turn, int(ref.split("_")[1]))
                    except (ValueError, IndexError):
                        pass
            if max_turn > 0 and node_max_turn > 0:
                score += 5.0 * (node_max_turn / max_turn)
            
            # 4. Attribute richness (0–2 points)
            n_attrs = len([k for k in node.attrs if k != "turn_ref"])
            score += min(n_attrs * 0.5, 2.0)
            
            scores[node_id] = score
        
        return scores
    
    def compact(self, max_nodes: int = 500) -> int:
        """
        Evict lowest-importance nodes to stay within max_nodes budget.
        
        GOAL and DECISION nodes are always preserved regardless of score.
        Edges referencing evicted nodes are also removed.
        
        Args:
            max_nodes: Maximum number of nodes to retain (default 500)
            
        Returns:
            Number of nodes evicted
        """
        if len(self.nodes) <= max_nodes:
            return 0
        
        scores = self.importance_scores()
        
        # Split into protected (GOAL/DECISION/FACT/PERSON) and evictable
        # Issue #140: FACT and PERSON nodes are now protected from compaction
        protected_ids: set[str] = set()
        evictable: list[tuple[str, float]] = []
        
        for node_id, node in self.nodes.items():
            if node.type in (NodeType.GOAL, NodeType.DECISION, NodeType.FACT, NodeType.PERSON):
                protected_ids.add(node_id)
            else:
                evictable.append((node_id, scores.get(node_id, 0.0)))
        
        # If protected nodes already exceed budget, keep them all, evict everything else
        keep_budget = max(max_nodes - len(protected_ids), 0)
        
        # Sort evictable by score descending — keep highest-scoring
        evictable.sort(key=lambda x: x[1], reverse=True)
        
        keep_ids = protected_ids | {nid for nid, _ in evictable[:keep_budget]}
        evict_ids = {nid for nid, _ in evictable[keep_budget:]}
        
        if not evict_ids:
            return 0
        
        # Remove evicted nodes
        for nid in evict_ids:
            del self.nodes[nid]
        
        # Remove edges referencing evicted nodes
        self.edges = [
            e for e in self.edges
            if e.src not in evict_ids and e.dst not in evict_ids
        ]
        
        # Rebuild label index and mark vector index dirty
        self._rebuild_label_index()
        self._index_dirty = True
        
        logger.info(
            f"Graph compaction: evicted {len(evict_ids)} nodes, "
            f"retained {len(self.nodes)} (protected={len(protected_ids)})"
        )
        
        return len(evict_ids)

    # ==========================================================================
    # Proactive Compaction with Summarization (Issue #125)
    # ==========================================================================

    def summarize_compact(
        self,
        policy: CompactionPolicy = CompactionPolicy.MODERATE,
        max_nodes: int = 500,
        trigger: str = "manual",
    ) -> CompactionEvent:
        """
        Issue #125: Proactive graph compaction with node summarization.

        Instead of simply evicting low-importance nodes (losing information),
        this method:
          1. Identifies low-importance candidate nodes per *policy* threshold.
          2. Groups candidates by NodeType.
          3. Merges each group into a single *summary* node whose label is a
             comma-separated list of the merged labels, and whose ``attrs``
             contain a ``summarized_from`` list of the original node IDs.
          4. Re-links edges: any edge that referenced a merged node is
             redirected to the summary node, deduplicating edge triples.
          5. If the graph still exceeds *max_nodes* after summarization, falls
             back to the legacy ``compact()`` eviction.

        GOAL and DECISION nodes are **never** summarized or evicted.

        Args:
            policy: Compaction aggressiveness (CONSERVATIVE / MODERATE / AGGRESSIVE).
            max_nodes: Hard ceiling on node count (applies after summarization).
            trigger: Label for the event record ("periodic", "max_nodes", "manual").

        Returns:
            CompactionEvent with before/after statistics.
        """
        event = CompactionEvent(
            nodes_before=len(self.nodes),
            edges_before=len(self.edges),
            policy=policy.value,
            trigger=trigger,
        )

        score_threshold = COMPACTION_SCORE_THRESHOLDS[policy]
        scores = self.importance_scores()

        # ── Identify candidates (never touch GOAL/DECISION/FACT/PERSON) ──
        # Issue #140: FACT and PERSON nodes are now protected from summarization
        candidates: dict[NodeType, list[str]] = {}  # type -> [node_ids]
        for node_id, node in list(self.nodes.items()):
            if node.type in (NodeType.GOAL, NodeType.DECISION, NodeType.FACT, NodeType.PERSON):
                continue
            if scores.get(node_id, 0.0) < score_threshold:
                candidates.setdefault(node.type, []).append(node_id)

        # ── Merge each type-group into a summary node ───────────────────
        summary_count = 0
        for node_type, ids in candidates.items():
            if len(ids) < 2:
                # Not worth summarising a single node
                continue

            # Build summary label (truncate if very long)
            labels = [self.nodes[nid].label for nid in ids]
            summary_label = ", ".join(labels)
            if len(summary_label) > 120:
                summary_label = summary_label[:117] + "..."

            # Merge attrs — union of turn_refs, count of sources
            merged_turn_refs: list[str] = []
            merged_attrs: dict = {}
            for nid in ids:
                n = self.nodes[nid]
                tr = n.attrs.get("turn_ref", [])
                if isinstance(tr, str):
                    tr = [tr]
                merged_turn_refs.extend(tr)
                for k, v in n.attrs.items():
                    if k != "turn_ref" and k not in merged_attrs:
                        merged_attrs[k] = v
            # Deduplicate turn refs preserving order
            seen: set[str] = set()
            unique_refs: list[str] = []
            for r in merged_turn_refs:
                if r not in seen:
                    seen.add(r)
                    unique_refs.append(r)
            merged_attrs["turn_ref"] = unique_refs
            merged_attrs["summarized_from"] = ids
            merged_attrs["summary_count"] = len(ids)

            # Create summary node with a new ID
            summary_id = self._next_id(node_type)
            summary_node = GraphNode(
                id=summary_id,
                type=node_type,
                label=summary_label,
                attrs=merged_attrs,
            )
            self.nodes[summary_id] = summary_node
            self._index_node_label(summary_node)

            # ── Redirect edges ──────────────────────────────────────────
            merged_set = set(ids)
            new_edges: list[GraphEdge] = []
            seen_triples: set[tuple[str, str, str]] = set()
            # Collect existing non-merged triples
            for e in self.edges:
                src = summary_id if e.src in merged_set else e.src
                dst = summary_id if e.dst in merged_set else e.dst
                # Skip self-loops created by merge
                if src == dst:
                    continue
                triple = (src, dst, e.rel.value)
                if triple not in seen_triples:
                    seen_triples.add(triple)
                    new_edges.append(GraphEdge(src=src, dst=dst, rel=e.rel, attrs=e.attrs))
            self.edges = new_edges

            # Remove original nodes
            for nid in ids:
                del self.nodes[nid]

            summary_count += 1
            event.nodes_evicted += len(ids)
            logger.debug(
                "Summary node %s (%s) merged %d nodes: %s",
                summary_id, node_type.value, len(ids), summary_label[:60],
            )

        event.summary_nodes_created = summary_count

        # ── Fallback: if still over budget, do legacy eviction ──────────
        if len(self.nodes) > max_nodes:
            extra_evicted = self.compact(max_nodes)
            event.nodes_evicted += extra_evicted

        # Rebuild indexes
        self._rebuild_label_index()
        self._index_dirty = True

        event.nodes_after = len(self.nodes)
        event.edges_after = len(self.edges)

        logger.info(
            "Summarize-compact (%s, trigger=%s): %d→%d nodes "
            "(%d evicted, %d summaries), %d→%d edges",
            policy.value, trigger,
            event.nodes_before, event.nodes_after,
            event.nodes_evicted, event.summary_nodes_created,
            event.edges_before, event.edges_after,
        )

        return event
    
    def to_dict(self) -> dict:
        """Serialize to compact dict."""
        d = {
            "n": [n.to_dict() for n in self.nodes.values()],
            "e": [e.to_dict() for e in self.edges],
            "m": self.metadata,
            # Issue #115: Persist dedup configuration
            "dedup_strategy": self._dedup_strategy.value,
            "dedup_threshold": self._similarity_threshold,
        }
        # Issue #123: Persist per-type thresholds (only non-default to keep compact)
        if self._type_thresholds:
            d["type_thresholds"] = {t.value: v for t, v in self._type_thresholds.items()}
        # Issue #132: Persist dedup stats so they accumulate across turns
        if self._dedup_stats.total_operations > 0:
            d["dedup_stats"] = {
                "nodes_added": self._dedup_stats.nodes_added,
                "nodes_merged": self._dedup_stats.nodes_merged,
                "exact_matches": self._dedup_stats.exact_matches,
                "semantic_matches": self._dedup_stats.semantic_matches,
            }
        return d
    
    @classmethod
    def from_dict(cls, d: dict) -> "UnifiedGraph":
        """Deserialize from dict."""
        graph = cls()
        for nd in d.get("n", []):
            node = GraphNode.from_dict(nd)
            graph.nodes[node.id] = node
        for ed in d.get("e", []):
            graph.edges.append(GraphEdge.from_dict(ed))
        graph.metadata = d.get("m", {})
        # Rebuild label index for deduplication support
        graph._rebuild_label_index()
        # Issue #115: Restore dedup configuration (backwards-compatible defaults)
        if "dedup_strategy" in d:
            try:
                graph._dedup_strategy = DeduplicationStrategy(d["dedup_strategy"])
            except ValueError:
                pass  # Keep default if invalid value
        if "dedup_threshold" in d:
            graph._similarity_threshold = float(d["dedup_threshold"])
        # Issue #123: Restore per-type thresholds
        if "type_thresholds" in d:
            restored = {}
            for type_val, thresh in d["type_thresholds"].items():
                try:
                    restored[NodeType(type_val)] = float(thresh)
                except (ValueError, KeyError):
                    pass  # Skip unknown types
            graph._type_thresholds = restored
        # Issue #132: Restore dedup stats
        if "dedup_stats" in d:
            ds = d["dedup_stats"]
            graph._dedup_stats = DeduplicationStats(
                nodes_added=ds.get("nodes_added", 0),
                nodes_merged=ds.get("nodes_merged", 0),
                exact_matches=ds.get("exact_matches", 0),
                semantic_matches=ds.get("semantic_matches", 0),
            )
        return graph
    
    # ==========================================================================
    # Query Methods
    # ==========================================================================
    
    def get_entities(self) -> list[GraphNode]:
        """Get WHO and WHAT nodes."""
        return [n for n in self.nodes.values() 
                if n.type in (NodeType.PERSON, NodeType.THING, NodeType.CONCEPT)]

    def get_facts(self) -> list[GraphNode]:
        """Issue #107: Get FACT nodes (quantitative data points & assertions)."""
        return [n for n in self.nodes.values() if n.type == NodeType.FACT]
    
    def get_goals(self) -> list[GraphNode]:
        """Get GOAL nodes."""
        return [n for n in self.nodes.values() if n.type == NodeType.GOAL]
    
    def get_decisions(self) -> list[GraphNode]:
        """Get WHY nodes (decisions)."""
        return [n for n in self.nodes.values() if n.type == NodeType.DECISION]
    
    def get_actions(self) -> list[GraphNode]:
        """Get NEXT-STEP nodes."""
        return [n for n in self.nodes.values() if n.type == NodeType.ACTION]

    def get_structural_edges(self) -> list[dict]:
        """Get dependency, conflict, blocker, and supersedes edges with resolved labels.
        
        Issue #111: These structural relationships are critical for context
        assembly but were previously dropped from all output paths.
        
        Returns:
            List of dicts with keys: src_id, src_label, rel, dst_id, dst_label
        """
        _STRUCTURAL_RELS = (
            EdgeType.DEPENDS_ON, EdgeType.CONFLICTS, 
            EdgeType.BLOCKS, EdgeType.SUPERSEDES,
        )
        result = []
        for edge in self.edges:
            if edge.rel not in _STRUCTURAL_RELS:
                continue
            src_node = self.nodes.get(edge.src)
            dst_node = self.nodes.get(edge.dst)
            if src_node and dst_node:
                result.append({
                    "src_id": edge.src,
                    "src_label": src_node.label,
                    "rel": edge.rel.value,
                    "dst_id": edge.dst,
                    "dst_label": dst_node.label,
                })
        return result

    def get_relationships_for(self, node_id: str) -> list[GraphEdge]:
        """Get all edges involving a node."""
        return [e for e in self.edges if e.src == node_id or e.dst == node_id]
    
    def get_decision_context(self, decision_id: str) -> dict:
        """
        Get full context for a decision (LEGACY - use get_decision_detail for Unified Deep).
        """
        decision = self.nodes.get(decision_id)
        if not decision or decision.type != NodeType.DECISION:
            return {}
        
        context = {
            "decision": decision.label,
            "alternatives": [],
            "reasons": [],
            "enables": []
        }
        
        for edge in self.edges:
            if edge.src == decision_id:
                if edge.rel == EdgeType.OVER:
                    alt_node = self.nodes.get(edge.dst)
                    if alt_node:
                        context["alternatives"].append({
                            "option": alt_node.label,
                            "rejected_because": edge.attrs.get("reason", "")
                        })
                elif edge.rel == EdgeType.BECAUSE:
                    reason_node = self.nodes.get(edge.dst)
                    if reason_node:
                        context["reasons"].append(reason_node.label)
                elif edge.rel == EdgeType.ENABLES:
                    goal_node = self.nodes.get(edge.dst)
                    if goal_node:
                        context["enables"].append(goal_node.label)
        
        return context
    
    # ==========================================================================
    # Unified Deep Query Methods (Rich Attribute Access)
    # ==========================================================================
    
    def get_decision_detail(self, decision_id: str) -> dict:
        """
        Get FULL decision detail with Unified Deep rich attributes.
        
        Returns complete decision context including:
        - rationale (full WHY text)
        - confidence score
        - context (situation)
        - trade_offs (list of gained/sacrificed)
        - alternatives with pros/cons/rejection_reason
        - evidence/reasons with source and confidence
        
        This is the Unified Deep version of get_decision_context().
        """
        decision = self.nodes.get(decision_id)
        if not decision or decision.type != NodeType.DECISION:
            return {}
        
        # Base decision info from node attrs
        # Type-normalize all fields — LLM may return wrong types
        # (e.g. rationale as list, trade_offs as string, evidence as string)
        raw_rationale = decision.attrs.get("rationale", "")
        raw_confidence = decision.attrs.get("confidence", 0.0)
        raw_trade_offs = decision.attrs.get("trade_offs", [])
        raw_evidence = decision.attrs.get("evidence", [])
        raw_trigger = decision.attrs.get("trigger", "")
        raw_turn_ref = decision.attrs.get("turn_ref", [])
        
        detail = {
            "id": decision_id,
            "decision": decision.label,
            "rationale": str(raw_rationale) if not isinstance(raw_rationale, str) else raw_rationale,
            "confidence": float(raw_confidence) if isinstance(raw_confidence, (int, float)) else 0.0,
            "context": str(decision.attrs.get("context", "") or ""),
            "trade_offs": raw_trade_offs if isinstance(raw_trade_offs, list) else ([raw_trade_offs] if raw_trade_offs else []),
            "turn_ref": raw_turn_ref if isinstance(raw_turn_ref, list) else ([raw_turn_ref] if raw_turn_ref else []),
            "alternatives": [],
            "reasons": [],
            "enables": [],
            "supersedes": None,
            # Issue #114: decision richness — evidence and trigger
            "evidence": raw_evidence if isinstance(raw_evidence, list) else ([raw_evidence] if raw_evidence else []),
            "trigger": str(raw_trigger) if not isinstance(raw_trigger, str) else raw_trigger,
        }
        
        # Collect rich edge data
        for edge in self.edges:
            if edge.src == decision_id:
                if edge.rel == EdgeType.OVER:
                    alt_node = self.nodes.get(edge.dst)
                    if alt_node:
                        # Type-normalize pros/cons — LLM may return strings
                        raw_pros = edge.attrs.get("pros", [])
                        raw_cons = edge.attrs.get("cons", [])
                        detail["alternatives"].append({
                            "option": alt_node.label,
                            "pros": raw_pros if isinstance(raw_pros, list) else ([raw_pros] if raw_pros else []),
                            "cons": raw_cons if isinstance(raw_cons, list) else ([raw_cons] if raw_cons else []),
                            "rejection_reason": str(edge.attrs.get("rejection_reason", "") or "")
                        })
                elif edge.rel == EdgeType.BECAUSE:
                    reason_node = self.nodes.get(edge.dst)
                    if reason_node:
                        detail["reasons"].append({
                            "claim": reason_node.label,
                            "source": edge.attrs.get("source", "inferred"),
                            "confidence": edge.attrs.get("confidence", 0.5),
                            "turn_ref": edge.attrs.get("turn_ref", "")
                        })
                elif edge.rel == EdgeType.ENABLES:
                    goal_node = self.nodes.get(edge.dst)
                    if goal_node:
                        detail["enables"].append(goal_node.label)
                elif edge.rel == EdgeType.SUPERSEDES:
                    old_decision = self.nodes.get(edge.dst)
                    if old_decision:
                        detail["supersedes"] = old_decision.label
        
        return detail
    
    def get_goal_hierarchy(self) -> dict:
        """
        Get hierarchical goal structure with Unified Deep attributes.
        
        Returns goals organized by type (mission -> goals -> tasks)
        with status tracking and confidence scores.
        """
        hierarchy = {
            "missions": [],
            "goals": [],
            "tasks": [],
        }
        
        for node in self.nodes.values():
            if node.type != NodeType.GOAL:
                continue
            
            goal_info = {
                "id": node.id,
                "content": node.label,
                "goal_type": node.attrs.get("goal_type", "goal"),
                "status": node.attrs.get("status", "active"),
                "confidence": node.attrs.get("confidence", 0.5),
                "turn_ref": node.attrs.get("turn_ref", []),
                "depends_on": [],
                "enables": [],
                "blocks": [],
            }
            
            # Collect dependencies from edges
            for edge in self.edges:
                if edge.src == node.id:
                    target = self.nodes.get(edge.dst)
                    if target:
                        if edge.rel == EdgeType.DEPENDS_ON:
                            goal_info["depends_on"].append(target.label)
                        elif edge.rel == EdgeType.ENABLES:
                            goal_info["enables"].append(target.label)
                        elif edge.rel == EdgeType.BLOCKS:
                            goal_info["blocks"].append(target.label)
            
            # Categorize by goal_type
            goal_type = goal_info["goal_type"]
            if goal_type == "mission":
                hierarchy["missions"].append(goal_info)
            elif goal_type == "task":
                hierarchy["tasks"].append(goal_info)
            else:
                hierarchy["goals"].append(goal_info)
        
        return hierarchy
    
    def get_active_goals(self) -> list[dict]:
        """Get only active goals with full attributes.
        
        Issue #116: 'pending' is normalized to 'active' by infer_goal_statuses(),
        so we only need to check for 'active' status.
        """
        return [
            {
                "id": node.id,
                "content": node.label,
                "goal_type": node.attrs.get("goal_type", "goal"),
                "status": node.attrs.get("status", "active"),
                "confidence": node.attrs.get("confidence", 0.5),
            }
            for node in self.nodes.values()
            if node.type == NodeType.GOAL 
            and (node.attrs.get("status", "active") or "active").lower() == "active"
        ]

    def get_completed_goals(self) -> list[dict]:
        """Get completed goals with full attributes.
        
        Issue #110: Surface completed goals in assembled context so the
        agent can answer questions about what has been accomplished.
        """
        return [
            {
                "id": node.id,
                "content": node.label,
                "goal_type": node.attrs.get("goal_type", "goal"),
                "status": node.attrs.get("status", "completed"),
                "confidence": node.attrs.get("confidence", 0.5),
            }
            for node in self.nodes.values()
            if node.type == NodeType.GOAL
            and node.attrs.get("status", "").lower() == "completed"
        ]

    def get_goals_by_status(self) -> dict[str, list[dict]]:
        """Get all goals grouped by status.
        
        Issue #110: Returns a dict mapping status -> list of goal dicts.
        Ensures context assembly can surface goals of any status.
        
        Returns:
            Dict with keys like 'active', 'completed', 'abandoned', 'blocked'
            mapping to lists of goal dicts.
        """
        grouped: dict[str, list[dict]] = {}
        for node in self.nodes.values():
            if node.type != NodeType.GOAL:
                continue
            status = (node.attrs.get("status", "active") or "active").lower()
            goal_dict = {
                "id": node.id,
                "content": node.label,
                "goal_type": node.attrs.get("goal_type", "goal"),
                "status": status,
                "confidence": node.attrs.get("confidence", 0.5),
            }
            grouped.setdefault(status, []).append(goal_dict)
        return grouped

    def infer_goal_statuses(self) -> None:
        """Post-extraction goal status inference from graph edges.
        
        Issue #116: Programmatically derive goal statuses from edge
        relationships so status is always consistent with graph structure.
        
        Rules applied (in order):
        1. Normalize "pending" → "active" (not a standard status)
        2. Goals with incoming BLOCKS edges from active blockers → blocked
        3. Blocked goals whose blockers are all completed → active (unblock)
        4. Goals whose enabling decision was superseded with no
           replacement → abandoned
        
        Completed and already-abandoned goals are never overridden.
        """
        # --- Step 1: Normalize "pending" → "active" ---
        for node in self.nodes.values():
            if node.type == NodeType.GOAL:
                status = (node.attrs.get("status", "active") or "active").lower()
                if status == "pending":
                    node.attrs["status"] = "active"

        # --- Step 2: Build edge indices ---
        # goal_id → list of blocker source node ids
        goal_blockers: dict[str, list[str]] = {}
        # old_decision_id → new_decision_id (supersedes)
        superseded_map: dict[str, str] = {}
        # decision_id → list of goal ids it enables
        decision_enables: dict[str, list[str]] = {}

        for edge in self.edges:
            if edge.rel == EdgeType.BLOCKS:
                goal_blockers.setdefault(edge.dst, []).append(edge.src)
            elif edge.rel == EdgeType.SUPERSEDES:
                superseded_map[edge.dst] = edge.src
            elif edge.rel in (EdgeType.ENABLES, EdgeType.CHOSE):
                decision_enables.setdefault(edge.src, []).append(edge.dst)

        # --- Step 3: Infer blocked / unblocked ---
        for goal_id, blocker_ids in goal_blockers.items():
            goal_node = self.nodes.get(goal_id)
            if not goal_node or goal_node.type != NodeType.GOAL:
                continue

            current = (goal_node.attrs.get("status", "active") or "active").lower()
            # Don't override completed or abandoned
            if current in ("completed", "abandoned"):
                continue

            has_active_blocker = False
            for bid in blocker_ids:
                blocker = self.nodes.get(bid)
                if blocker:
                    bstatus = (blocker.attrs.get("status", "active") or "active").lower()
                    if bstatus not in ("completed", "resolved"):
                        has_active_blocker = True
                        break

            if has_active_blocker:
                goal_node.attrs["status"] = "blocked"
            elif current == "blocked":
                # All blockers resolved → unblock
                goal_node.attrs["status"] = "active"

        # --- Step 4: Infer abandoned from supersession ---
        for old_dec_id, new_dec_id in superseded_map.items():
            enabled_goals = decision_enables.get(old_dec_id, [])
            new_enabled = set(decision_enables.get(new_dec_id, []))

            for gid in enabled_goals:
                goal_node = self.nodes.get(gid)
                if not goal_node or goal_node.type != NodeType.GOAL:
                    continue

                current = (goal_node.attrs.get("status", "active") or "active").lower()
                if current in ("completed", "abandoned"):
                    continue

                # Goal's approach was superseded without replacement
                if gid not in new_enabled:
                    goal_node.attrs["status"] = "abandoned"
                    if "abandoned_reason" not in goal_node.attrs:
                        old_label = self.nodes[old_dec_id].label if old_dec_id in self.nodes else old_dec_id
                        new_label = self.nodes[new_dec_id].label if new_dec_id in self.nodes else new_dec_id
                        goal_node.attrs["abandoned_reason"] = (
                            f"approach '{old_label}' superseded by '{new_label}'"
                        )
    
    def get_all_decisions_with_detail(self) -> list[dict]:
        """Get all decisions with full Unified Deep detail."""
        return [
            self.get_decision_detail(node.id)
            for node in self.nodes.values()
            if node.type == NodeType.DECISION
        ]
    
    def keyword_search(
        self, 
        query: str, 
        limit: int = 10,
        include_types: Optional[list[NodeType]] = None
    ) -> list[dict]:
        """
        Search unified graph for nodes using keyword matching.
        
        For most use cases, prefer ``search()`` which uses semantic (vector)
        similarity and is both faster and more accurate.
        
        Uses keyword-based matching with relevance scoring:
        - Exact match: score 1.0
        - Partial word match: score 0.7
        - Related via edge: score 0.5
        
        Args:
            query: Search query text
            limit: Maximum results to return
            include_types: Optional filter for specific node types
            
        Returns:
            List of dicts with node info and relevance score, sorted by score
        """
        if not query or not self.nodes:
            return []
        
        query_lower = query.lower()
        query_words = set(query_lower.split())
        
        results = []
        matched_ids = set()
        
        # Score each node
        for node in self.nodes.values():
            # Filter by type if specified
            if include_types and node.type not in include_types:
                continue
            
            label_lower = node.label.lower()
            label_words = set(label_lower.split())
            
            score = 0.0
            
            # Exact substring match
            if query_lower in label_lower:
                score = 1.0
            # Word overlap
            elif query_words & label_words:
                overlap = len(query_words & label_words) / len(query_words)
                score = 0.7 * overlap
            # Check attributes for matches
            elif node.attrs:
                for key, val in node.attrs.items():
                    if isinstance(val, str) and query_lower in val.lower():
                        score = 0.6
                        break
                    elif isinstance(val, list):
                        for item in val:
                            if isinstance(item, str) and query_lower in item.lower():
                                score = 0.5
                                break
            
            if score > 0:
                result = {
                    "id": node.id,
                    "type": node.type.name.lower(),
                    "content": node.label,
                    "score": score,
                    "attributes": node.attrs,
                }
                results.append(result)
                matched_ids.add(node.id)
        
        # Add related nodes via edges (lower score)
        for edge in self.edges:
            for matched_id in list(matched_ids):
                related_id = None
                if edge.src == matched_id and edge.dst not in matched_ids:
                    related_id = edge.dst
                elif edge.dst == matched_id and edge.src not in matched_ids:
                    related_id = edge.src
                
                if related_id and related_id not in matched_ids:
                    related_node = self.nodes.get(related_id)
                    if related_node:
                        if include_types and related_node.type not in include_types:
                            continue
                        results.append({
                            "id": related_node.id,
                            "type": related_node.type.name.lower(),
                            "content": related_node.label,
                            "score": 0.4,
                            "attributes": related_node.attrs,
                            "related_to": matched_id,
                        })
                        matched_ids.add(related_id)
        
        # Sort by score descending, take top limit
        results.sort(key=lambda x: x["score"], reverse=True)
        return results[:limit]
    
    def summary(self) -> dict:
        """Get graph summary statistics."""
        type_counts = {}
        for node in self.nodes.values():
            type_counts[node.type.value] = type_counts.get(node.type.value, 0) + 1
        
        return {
            "total_nodes": len(self.nodes),
            "total_edges": len(self.edges),
            "by_type": type_counts,
            "entities": len(self.get_entities()),
            "goals": len(self.get_goals()),
            "decisions": len(self.get_decisions()),
            "actions": len(self.get_actions()),
            "facts": len(self.get_facts()),  # Issue #107
        }

    # =========================================================================
    # Semantic Search (Issue #51)
    # =========================================================================
    
    def build_vector_index(self, force: bool = False) -> "VectorIndex":
        """
        Build FAISS vector index from node embeddings.
        
        Issue #93: Cached with dirty flag - only rebuilds when nodes change.
        Called lazily on first semantic search. Very fast (<0.2ms for 1000 nodes).
        Also stores the index internally for deduplication semantic matching.
        
        Args:
            force: Force rebuild even if index is clean
        
        Returns:
            VectorIndex populated with all node embeddings
        """
        # Return cached index if clean
        if not force and not self._index_dirty and self._vector_index is not None:
            return self._vector_index
        
        from .vector_index import VectorIndex
        
        index = VectorIndex()
        nodes_with_embeddings = [
            (node_id, node.embedding)
            for node_id, node in self.nodes.items()
            if node.embedding is not None
        ]
        
        if nodes_with_embeddings:
            index.add_batch(nodes_with_embeddings)
        
        # Store internally for deduplication and cache
        self._vector_index = index
        self._index_dirty = False  # Issue #93: mark clean
        
        return index
    
    def search(
        self,
        query: str,
        k: int = 10,
        node_types: Optional[list[NodeType]] = None,
        min_score: float = 0.0
    ) -> list[tuple[GraphNode, float]]:
        """
        Search graph nodes by semantic similarity (primary search API).
        
        Uses FAISS vector search on node embeddings for fast, relevant results.
        Renamed from ``semantic_search()`` in Issue #98.
        
        Args:
            query: Natural language search query
            k: Maximum number of results
            node_types: Optional filter for specific node types (e.g., [NodeType.GOAL])
            min_score: Minimum similarity score threshold (0.0 to 1.0)
            
        Returns:
            List of (GraphNode, similarity_score) tuples, sorted by score descending
            
        Example:
            # Find decisions about databases
            results = graph.search("database choice", k=5, 
                                   node_types=[NodeType.DECISION])
            for node, score in results:
                print(f"{node.label}: {score:.3f}")
        """
        # Embed query (Issue #98: cached embedder)
        # Issue #104: Wrap embedding and index ops in error handling
        try:
            query_embedding = self._get_embedder().embed_text(query)
        except Exception as e:
            logger.error(f"Failed to embed search query: {e}")
            return []
        
        # Issue #130: Generate embeddings for nodes that don't have them yet.
        # Nodes created during add()/extract() are intentionally created without
        # embeddings (lazy loading — avoids loading sentence-transformers during
        # add()). Now that _get_embedder() has loaded the model for the query
        # above, we can batch-embed all pending nodes before building the index.
        self._generate_all_embeddings()
        
        # Build index (cached via dirty flag - Issue #93)
        try:
            index = self.build_vector_index()
        except Exception as e:
            logger.error(f"Failed to build vector index: {e}")
            return []
        
        if len(index) == 0:
            return []
        
        # Over-fetch to allow for type filtering
        fetch_k = k * 3 if node_types else k
        raw_results = index.search(query_embedding, fetch_k)
        
        # Filter and collect results
        results = []
        for node_id, score in raw_results:
            if score < min_score:
                continue
                
            node = self.nodes.get(node_id)
            if node is None:
                continue
                
            if node_types and node.type not in node_types:
                continue
                
            results.append((node, score))
            
            if len(results) >= k:
                break
        
        return results
    
    def semantic_search(
        self,
        query: str,
        k: int = 10,
        node_types: Optional[list[NodeType]] = None,
        min_score: float = 0.0
    ) -> list[tuple[GraphNode, float]]:
        """Deprecated alias for :meth:`search`. Use ``search()`` instead."""
        import warnings
        warnings.warn(
            "semantic_search() is deprecated, use search() instead (Issue #98)",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.search(query, k, node_types, min_score)
    
    def search_goals(
        self, 
        query: str, 
        k: int = 5,
        min_score: float = 0.0
    ) -> list[tuple[GraphNode, float]]:
        """
        Find goals related to query.
        
        Args:
            query: Search query
            k: Max results
            min_score: Minimum similarity threshold
            
        Returns:
            List of (goal_node, score) tuples
        """
        return self.search(query, k, node_types=[NodeType.GOAL], min_score=min_score)
    
    def search_decisions(
        self, 
        query: str, 
        k: int = 5,
        min_score: float = 0.0
    ) -> list[tuple[GraphNode, float]]:
        """
        Find decisions related to query.
        
        Useful for "why" queries - understanding past choices.
        
        Args:
            query: Search query
            k: Max results
            min_score: Minimum similarity threshold
            
        Returns:
            List of (decision_node, score) tuples
        """
        return self.search(query, k, node_types=[NodeType.DECISION], min_score=min_score)
    
    def search_things(
        self, 
        query: str, 
        k: int = 5,
        min_score: float = 0.0
    ) -> list[tuple[GraphNode, float]]:
        """
        Find things (technologies, tools, products) related to query.
        
        Args:
            query: Search query
            k: Max results
            min_score: Minimum similarity threshold
            
        Returns:
            List of (thing_node, score) tuples
        """
        return self.search(query, k, node_types=[NodeType.THING], min_score=min_score)
    
    def search_all_types(
        self, 
        query: str, 
        k_per_type: int = 3,
        min_score: float = 0.0
    ) -> dict[str, list[tuple[GraphNode, float]]]:
        """
        Search across all node types, returning top results for each.
        
        Useful for comprehensive context retrieval.
        
        Args:
            query: Search query
            k_per_type: Max results per node type
            min_score: Minimum similarity threshold
            
        Returns:
            Dict mapping type name to list of (node, score) tuples
        """
        return {
            "goals": self.search_goals(query, k_per_type, min_score),
            "decisions": self.search_decisions(query, k_per_type, min_score),
            "things": self.search_things(query, k_per_type, min_score),
            "concepts": self.search(query, k_per_type, [NodeType.CONCEPT], min_score),
            "actions": self.search(query, k_per_type, [NodeType.ACTION], min_score),
            "persons": self.search(query, k_per_type, [NodeType.PERSON], min_score),
        }


# =============================================================================
# LLM Protocol
# =============================================================================

class LLMClient(Protocol):
    """Protocol for LLM client implementations."""
    
    async def complete(self, prompt: str, system: Optional[str] = None) -> str:
        """Generate a completion for the given prompt."""
        ...


# =============================================================================
# Unified Extraction Prompt (Token-Optimized)
# =============================================================================

UNIFIED_EXTRACTION_SYSTEM = """You extract structured knowledge graphs with FULL REASONING DEPTH.

Output a JSON graph with:
- n: nodes (entities, goals, decisions, actions, facts) - with rich attributes
- e: edges (relationships) - with rich attributes

GRAMMAR-AWARE NODE TYPE GUIDE (Issue #108):
  Entities (noun phrases — what EXISTS):
    P = Person  — proper/common nouns: people, users, stakeholders (e.g., "Alice", "the client")
    T = Thing   — concrete noun phrases: products, tools, technologies (e.g., "FastAPI", "PostgreSQL")
    C = Concept — abstract noun phrases: ideas, approaches, methodologies (e.g., "microservices", "agile")
  Events (verb phrases — what HAPPENS or is CHOSEN):
    G = Goal     — infinitive/VP: what user wants TO DO (e.g., "build REST API", "reduce latency")
    D = Decision — imperative/VP: what user CHOSE to do (e.g., "use FastAPI", "switch to PostgreSQL")
    A = Action   — nominalized VP: tasks, next steps (e.g., "migrate stored procedures", "deploy to staging")
  Propositions (declarative clauses — what is TRUE):
    F = Fact     — full clauses: quantitative data, measurements, assertions (e.g., "salary is $95k/year", "team has 8 engineers")

Edge types: uses, wants, chose, over, because, enables, blocks, next, relates, part_of, depends_on, conflicts, supersedes

CRITICAL RULES FOR UNIFIED DEEP:
1. Extract EVERY technology/tool/framework/product by exact name
2. GOAL nodes MUST have attrs: goal_type (mission|goal|task), status (active|completed|blocked|abandoned), confidence (0.0-1.0)
3. DECISION nodes MUST have attrs: rationale (full WHY text), confidence, context, trade_offs list, alternatives list (names of rejected options), evidence (quantitative data/metrics supporting the decision), trigger (what event/discussion prompted this decision)
4. OVER edges (alternatives) MUST have attrs: pros list, cons list, rejection_reason — create an OVER edge for EVERY alternative considered
5. BECAUSE edges MUST have attrs: source (user_stated|inferred|external), confidence
6. Capture ALL alternatives even if briefly mentioned or rejected - use 'over' edge with full pros/cons. If someone says "we chose X over Y and Z", create OVER edges for BOTH Y and Z
7. Use depends_on for task dependencies, conflicts for incompatible goals, supersedes for updated decisions
8. DECISION evidence: When a decision is backed by numbers, benchmarks, metrics, or data (e.g., "3x faster", "200ms latency", "saves $50k/year"), capture that in the evidence attr as a list of strings

GOAL LIFECYCLE STATUS RULES:
- active: Goal is being pursued, no blockers
- blocked: Goal has active blockers preventing progress — MUST also have a blocks edge from the blocker to the goal
- completed: Goal has been achieved
- abandoned: Goal has been explicitly dropped or its approach was superseded without replacement
- If something blocks a goal, create a BLOCKS edge AND set status to "blocked"
- If a goal is no longer being pursued, set status to "abandoned" with reason in attrs
- Do NOT use "pending" — use "active" for goals not yet started but planned

LABEL NORMALIZATION — ALWAYS apply:
- Normalize informal, colloquial, or ungrammatical user language into clean standard English labels
- Fix slang: "gonna" → "going to", "wanna" → "want to", "k" → "thousand", "k8s" → "Kubernetes"
- Fix grammar: "me salary" → "salary", "we was thinking" → extract intent as proper VP
- Drop filler words: "like", "basically", "kinda", "sort of", "um", "uh"
- Preserve exact numbers and proper nouns even if misspelled — do NOT invent values
- Example: user says "gonna pay like 150k-ish for the devs" → FACT label: "developer budget is approximately $150,000"

FACT NODE RULES (Issue #107):
- Extract EVERY numeric value, dollar amount, percentage, date, metric, or quantitative assertion as a FACT node
- FACT label should be the full declarative statement (e.g., "annual salary is $95,000")
- FACT attrs MUST include: value (the number), unit (USD, %, months, etc.), category (income|expense|metric|timeline|count)
- When a fact changes, use update_n to modify value + add as_of turn reference
- Link FACT nodes to relevant entities/goals with "relates" edges

CRITICAL - NUMERIC & QUANTITATIVE DATA:
8. ALWAYS preserve specific numbers, counts, percentages, dollar amounts, timelines, and metrics in node attrs
9. Use these attribute keys for numeric data: count, quantity, percentage, amount, timeline, duration, metric, target, breakdown, value, unit, category
10. Include numbers in labels when they are central to the concept (e.g., "8 backend engineers" not just "backend engineers")
11. For team/resource nodes: include count and breakdown in attrs (e.g., a: {count: 8, breakdown: "5 Java, 2 Go, 1 Rust"})
12. For metric/measurement nodes: include the specific value (e.g., a: {percentage: 85, metric: "test_coverage"})
13. For financial/budget nodes: include amount and currency (e.g., a: {amount: 50000, currency: "USD"})
14. For timeline/deadline nodes: include duration or date (e.g., a: {timeline: "8 weeks", target_date: "Q3 2025"})
15. For progress/status: include numeric progress (e.g., a: {completed: 30, total: 47, percentage: 63.8})"""

UNIFIED_EXTRACTION_PROMPT = """Extract knowledge graph with FULL REASONING DEPTH from this conversation:

{conversation}

Output JSON with RICH ATTRIBUTES (IMPORTANT: preserve ALL numbers, counts, percentages, dollar amounts, timelines):
{{
  "n": [
    {{"id": "P1", "t": "P", "l": "user"}},
    {{"id": "G1", "t": "G", "l": "build REST API", "a": {{
      "goal_type": "goal",
      "status": "active",
      "confidence": 0.9,
      "turn_ref": ["turn_1"]
    }}}},
    {{"id": "D1", "t": "D", "l": "use FastAPI", "a": {{
      "rationale": "FastAPI provides better async performance and automatic OpenAPI docs",
      "confidence": 0.85,
      "context": "Choosing web framework for REST API",
      "trade_offs": [{{"gained": "performance + auto-docs", "sacrificed": "Django ecosystem"}}],
      "alternatives": ["Django", "Flask"],
      "evidence": ["3x faster than Flask in benchmarks", "automatic OpenAPI spec generation"],
      "trigger": "Need to choose web framework for new REST API project",
      "turn_ref": ["turn_2", "turn_3"]
    }}}},
    {{"id": "T1", "t": "T", "l": "FastAPI"}},
    {{"id": "T2", "t": "T", "l": "Django"}},
    {{"id": "T3", "t": "T", "l": "8 backend engineers", "a": {{"count": 8, "breakdown": "5 Java, 2 Go, 1 Rust"}}}},
    {{"id": "C1", "t": "C", "l": "test coverage at 85%", "a": {{"percentage": 85, "metric": "test_coverage"}}}},
    {{"id": "C2", "t": "C", "l": "automatic OpenAPI docs"}},
    {{"id": "A1", "t": "A", "l": "migrate 47 stored procedures", "a": {{"total": 47, "completed": 30, "percentage": 63.8}}}},
    {{"id": "F1", "t": "F", "l": "annual salary is $95,000", "a": {{"value": 95000, "unit": "USD", "category": "income", "as_of": "turn_1"}}}},
    {{"id": "F2", "t": "F", "l": "deployment target is 8 weeks", "a": {{"value": 8, "unit": "weeks", "category": "timeline", "as_of": "turn_3"}}}}
  ],
  "e": [
    {{"s": "P1", "d": "G1", "r": "wants"}},
    {{"s": "D1", "d": "T1", "r": "chose"}},
    {{"s": "D1", "d": "T2", "r": "over", "a": {{
      "pros": ["mature ecosystem", "built-in admin", "ORM"],
      "cons": ["sync by default", "heavier weight"],
      "rejection_reason": "Need async performance for high-throughput API"
    }}}},
    {{"s": "D1", "d": "C1", "r": "because", "a": {{
      "source": "user_stated",
      "confidence": 0.9,
      "turn_ref": "turn_2"
    }}}},
    {{"s": "D1", "d": "C2", "r": "because", "a": {{
      "source": "inferred",
      "confidence": 0.7,
      "turn_ref": "turn_3"
    }}}},
    {{"s": "D1", "d": "G1", "r": "enables"}},
    {{"s": "G1", "d": "A1", "r": "next"}},
    {{"s": "F1", "d": "P1", "r": "relates"}},
    {{"s": "F2", "d": "G1", "r": "relates"}}
  ]
}}

EXTRACTION RULES FOR UNIFIED DEEP:
1. Extract EVERY technology/tool/framework/language by EXACT name
2. GOAL nodes (verb phrases): Always include goal_type, status, confidence in attrs
3. DECISION nodes (verb phrases): Always include rationale (full text), confidence, context, trade_offs, alternatives (list of rejected option names), evidence (list of supporting data/metrics), trigger (what prompted the decision)
4. OVER edges: For EVERY alternative mentioned, create an OVER edge with pros, cons, rejection_reason attrs
5. BECAUSE edges: Always include source (user_stated/inferred/external), confidence
6. Use depends_on edge when one task requires another
7. Use conflicts edge when goals are mutually exclusive
8. Use supersedes edge when a decision updates/replaces a previous one
9. Include next actions or planned steps
10. Only output valid JSON - no extra text
11. DECISION evidence: If a decision references benchmarks, metrics, latency, cost, or any quantitative justification, capture each as a string in the evidence list attr
12. DECISION alternatives: If someone says "we considered X and Y but chose Z", the alternatives attr should be ["X", "Y"] AND create OVER edges for both X and Y
13. FACT nodes (declarative clauses): Extract EVERY numeric value, dollar amount, percentage, date, count, metric as a FACT node with value, unit, category attrs. Link to relevant entities/goals with "relates" edges.

CRITICAL - NUMERIC DATA PRESERVATION:
11. ALWAYS capture specific numbers in BOTH the label AND the attrs
12. Counts/quantities: include count or quantity attr (e.g., "50k daily users" -> a: {{"count": 50000}})
13. Percentages: include percentage attr (e.g., "85% coverage" -> a: {{"percentage": 85}})
14. Dollar amounts: include amount attr (e.g., "$300k budget" -> a: {{"amount": 300000}})
15. Timelines: include timeline or duration attr (e.g., "8 weeks" -> a: {{"timeline": "8 weeks"}})
16. Progress: include completed/total attrs (e.g., "30 of 47 done" -> a: {{"completed": 30, "total": 47}})
17. Breakdowns: include breakdown attr (e.g., "5 Java, 2 Go, 1 Rust" -> a: {{"breakdown": "5 Java, 2 Go, 1 Rust"}})"""


DELTA_EXTRACTION_PROMPT = """Analyze new conversation content and extract NEW information to add to the existing knowledge graph.

EXISTING NODES (do NOT repeat these, they already exist):
{existing_graph}

NEW CONVERSATION:
{new_messages}

Extract ONLY genuinely NEW information not already in the graph above.
Output JSON with:
- "add_n": New nodes (entities, goals, decisions, actions, facts) NOT in existing list
- "add_e": New relationships between nodes  
- "update_n": Updates to existing node attributes (e.g., status changes, new numeric data, updated fact values)

Format:
{{
  "add_n": [{{"id": "T1", "t": "T", "l": "NewTechnology", "a": {{}}}}, {{"id": "F1", "t": "F", "l": "budget is $300k", "a": {{"value": 300000, "unit": "USD", "category": "budget"}}}}],
  "add_e": [{{"s": "G1", "d": "T1", "r": "uses"}}, {{"s": "F1", "d": "G1", "r": "relates"}}],
  "update_n": [{{"id": "G1", "a": {{"status": "completed"}}}}, {{"id": "F1", "a": {{"value": 350000, "as_of": "turn_8"}}}}]
}}

GRAMMAR-AWARE NODE TYPES (Issue #108):
  Entities (noun phrases): P=Person, T=Thing, C=Concept
  Events (verb phrases):   G=Goal, D=Decision, A=Action
  Propositions (clauses):  F=Fact (numeric data, measurements, assertions)
Edge types: uses, wants, chose, over, because, enables, blocks, next, relates, part_of, depends_on, conflicts, supersedes

CRITICAL - DECISION RICHNESS:
- DECISION nodes MUST have attrs: rationale, confidence, context, trade_offs, alternatives (list of rejected options), evidence (list of supporting data/metrics), trigger
- For EVERY alternative mentioned, create an OVER edge with pros, cons, rejection_reason attrs
- If someone says "we chose X over Y and Z", create OVER edges for BOTH Y and Z
- Evidence: capture benchmarks, metrics, numbers backing the decision (e.g., ["3x faster", "saves $50k/year"])
- Trigger: what event or discussion prompted this decision

LABEL NORMALIZATION — ALWAYS apply:
- Normalize informal/colloquial language into clean standard English labels
- Fix slang ("gonna" → VP, "k" → "thousand"), grammar, filler words
- Preserve exact numbers and proper nouns — do NOT invent values

FACT NODE RULES (Issue #107):
- Extract EVERY new numeric value, dollar amount, percentage, date, count as a FACT node
- FACT label = full declarative statement; attrs MUST include value, unit, category
- When a value changes (e.g., salary increase), use update_n on the existing FACT: {{"id": "F1", "a": {{"value": 102000, "as_of": "turn_8"}}}}
- Link FACT nodes to relevant entities/goals with "relates" edges

GOAL LIFECYCLE STATUS TRANSITIONS:
- Valid statuses: active, completed, blocked, abandoned (do NOT use "pending")
- Use update_n to change goal status when conversation indicates:
  - Goal completed: {{"id": "G1", "a": {{"status": "completed"}}}}
  - Goal blocked: {{"id": "G1", "a": {{"status": "blocked"}}}} — MUST also add a blocks edge
  - Goal abandoned: {{"id": "G1", "a": {{"status": "abandoned", "abandoned_reason": "superseded by new approach"}}}}
- If a new decision supersedes the approach for a goal, update the goal status to abandoned
- If a blocker is resolved, update the blocked goal status back to active

CRITICAL - NUMERIC DATA PRESERVATION:
- ALWAYS capture specific numbers, counts, percentages, dollar amounts, timelines in BOTH labels AND attrs
- Use attrs: count, quantity, percentage, amount, timeline, duration, completed, total, breakdown, value, unit, category
- Example: {{"id": "F1", "t": "F", "l": "team has 50k daily users", "a": {{"value": 50000, "unit": "users", "category": "metric"}}}}
- Example: {{"id": "F2", "t": "F", "l": "test coverage is 85%", "a": {{"value": 85, "unit": "%", "category": "metric"}}}}
- If an existing node is missing numeric attrs, use update_n to add them

Output valid JSON only - no explanations."""


# =============================================================================
# Issue #139: Configurable Extraction Profiles
# =============================================================================

# --- Conversational System Prompt ---
CONVERSATIONAL_EXTRACTION_SYSTEM = """You extract structured knowledge graphs from personal conversations.

Output a JSON graph with:
- n: nodes (people, facts, preferences, events, relationships) - with rich attributes
- e: edges (relationships between nodes) - with descriptive attributes

GRAMMAR-AWARE NODE TYPE GUIDE:
  Entities (noun phrases — what EXISTS):
    P = Person  — people mentioned by name or relation: "Alice", "my sister", "the doctor"
    T = Thing   — concrete objects, pets, places, items: "Max" (dog), "Paris", "red bicycle"
    C = Concept — abstract topics, activities, preferences: "hiking", "vegetarian diet", "jazz music"
  Events (verb phrases — what HAPPENS):
    G = Goal     — what someone wants to do: "learn French", "visit Japan", "get promoted"
    D = Decision — what someone chose: "adopted a dog", "switched to vegan", "moved to Austin"
    A = Action   — tasks, scheduled events: "starting school in September", "flying to London Tuesday"
  Propositions (declarative clauses — what is TRUE):
    F = Fact     — personal details, preferences, opinions, numeric data: "sister is a nurse", "loves hiking", "salary is $95k"

Edge types: knows, family, friend, colleague, prefers, likes, dislikes, lives_in, works_at, relates, part_of, wants, chose, scheduled

CRITICAL EXTRACTION RULES:
1. Extract EVERY person mentioned by name with all known attributes (age, occupation, relationship to user)
2. Extract ALL personal preferences, likes, dislikes, opinions, and sentiments
3. Extract ALL events, activities, temporal facts — preserve dates, seasons, "last week", "next month"
4. Extract ALL relationships (family, friends, colleagues, romantic, pets)
5. Extract occupations, locations, ages, and other personal details
6. Extract emotional states, sentiments, and opinions
7. FACT nodes: cover ALL factual statements — not just numeric ones
8. Include turn_ref for temporal ordering
9. Preserve exact names, dates, quantities, and proper nouns
10. ALWAYS capture specific numbers in BOTH the label AND the attrs"""

# --- Conversational Full Extraction Prompt ---
EXTRACTION_PROMPT_CONVERSATIONAL = """Extract knowledge graph capturing ALL personal, social, and factual information from this conversation:

{conversation}

Output JSON with nodes and edges:
{{
  "n": [
    {{"id": "P1", "t": "P", "l": "user"}},
    {{"id": "P2", "t": "P", "l": "Alice", "a": {{"relationship": "sister", "occupation": "nurse", "age": 32}}}},
    {{"id": "F1", "t": "F", "l": "user loves hiking", "a": {{"category": "preference", "sentiment": "positive"}}}},
    {{"id": "F2", "t": "F", "l": "visited Paris last summer", "a": {{"category": "event", "timeframe": "last summer"}}}},
    {{"id": "D1", "t": "D", "l": "adopted a dog named Max", "a": {{"timeframe": "last week"}}}},
    {{"id": "T1", "t": "T", "l": "Max", "a": {{"type": "pet", "species": "dog"}}}},
    {{"id": "F3", "t": "F", "l": "prefers window seats on flights", "a": {{"category": "preference"}}}},
    {{"id": "G1", "t": "G", "l": "learn to play guitar", "a": {{"status": "active"}}}},
    {{"id": "F4", "t": "F", "l": "annual salary is $95,000", "a": {{"value": 95000, "unit": "USD", "category": "income"}}}}
  ],
  "e": [
    {{"s": "P1", "d": "P2", "r": "family", "a": {{"type": "sibling"}}}},
    {{"s": "P1", "d": "T1", "r": "owns"}},
    {{"s": "D1", "d": "T1", "r": "about"}},
    {{"s": "F1", "d": "P1", "r": "relates"}},
    {{"s": "G1", "d": "P1", "r": "relates"}},
    {{"s": "F4", "d": "P1", "r": "relates"}}
  ]
}}

EXTRACTION RULES FOR CONVERSATIONAL:
1. Extract EVERY person mentioned by name — include relationship, occupation, age, location in attrs
2. Extract ALL personal preferences, likes, dislikes, opinions as FACT nodes with category="preference"
3. Extract ALL events, activities, temporal facts (dates, seasons, "last week", "next Tuesday") as FACT or ACTION nodes
4. Extract ALL relationships (family, friends, colleagues, pets) as edges with type attr
5. Extract occupations, work details, education as FACT nodes linked to the person
6. Extract locations (lives, visited, plans to visit) as FACT or THING nodes
7. Extract emotional states and sentiments as FACT nodes with sentiment attr
8. FACT nodes: cover ALL factual statements, not just numeric ones. Include value/unit for quantitative facts
9. Include turn_ref for temporal ordering on all nodes
10. Preserve exact names, dates, and quantities — do NOT paraphrase proper nouns
11. For numeric data: ALWAYS capture in BOTH label AND attrs with value, unit, category keys
12. Only output valid JSON — no extra text"""

# --- Conversational Delta Extraction Prompt ---
DELTA_PROMPT_CONVERSATIONAL = """Analyze new conversation content and extract NEW personal, social, and factual information to add to the existing knowledge graph.

EXISTING NODES (do NOT repeat these, they already exist):
{existing_graph}

NEW CONVERSATION:
{new_messages}

Extract ONLY genuinely NEW information not already in the graph above.
Output JSON with:
- "add_n": New nodes (people, facts, preferences, events, relationships) NOT in existing list
- "add_e": New relationships between nodes
- "update_n": Updates to existing node attributes (e.g., new info about a person, changed preference)

Format:
{{
  "add_n": [{{"id": "P3", "t": "P", "l": "Bob", "a": {{"relationship": "colleague", "occupation": "designer"}}}}, {{"id": "F5", "t": "F", "l": "planning trip to Japan", "a": {{"category": "plan", "timeframe": "next spring"}}}}],
  "add_e": [{{"s": "P1", "d": "P3", "r": "colleague"}}],
  "update_n": [{{"id": "P2", "a": {{"occupation": "senior nurse", "as_of": "turn_8"}}}}]
}}

GRAMMAR-AWARE NODE TYPES:
  Entities (noun phrases): P=Person, T=Thing, C=Concept
  Events (verb phrases):   G=Goal, D=Decision, A=Action
  Propositions (clauses):  F=Fact
Edge types: knows, family, friend, colleague, prefers, likes, dislikes, lives_in, works_at, relates, part_of, wants, chose, scheduled

CONVERSATIONAL EXTRACTION RULES:
1. Extract EVERY new person mentioned — include relationship, occupation, age, location in attrs
2. Extract ALL new personal preferences, likes, dislikes, opinions
3. Extract ALL new events, activities, temporal facts with timeframe attrs
4. Extract ALL new relationships (family, friends, colleagues, pets)
5. Extract occupations, locations, ages, and other personal details not already in graph
6. Extract emotional states, sentiments, and opinions
7. FACT nodes: cover ALL new factual statements, not just numeric ones
8. When a person's details change (new job, moved location), use update_n
9. Preserve exact names, dates, quantities — do NOT paraphrase proper nouns

LABEL NORMALIZATION — ALWAYS apply:
- Normalize informal/colloquial language into clean standard English labels
- Fix slang: "gonna" → "going to", "wanna" → "want to"
- Preserve exact numbers and proper nouns — do NOT invent values

CRITICAL - NUMERIC DATA PRESERVATION:
- ALWAYS capture specific numbers, counts, percentages, dollar amounts, timelines in BOTH labels AND attrs
- Use attrs: count, quantity, percentage, amount, timeline, duration, value, unit, category
- If an existing node is missing info revealed in new messages, use update_n

Output valid JSON only - no explanations."""


# --- Comprehensive System Prompt (union of decision + conversational) ---
COMPREHENSIVE_EXTRACTION_SYSTEM = """You extract structured knowledge graphs with FULL DEPTH from conversations, capturing BOTH technical/decision content AND personal/social content.

Output a JSON graph with:
- n: nodes (entities, goals, decisions, actions, facts, people, preferences) - with rich attributes
- e: edges (relationships) - with rich attributes

GRAMMAR-AWARE NODE TYPE GUIDE:
  Entities (noun phrases — what EXISTS):
    P = Person  — people: users, stakeholders, family members, colleagues ("Alice", "the CTO", "my sister")
    T = Thing   — concrete noun phrases: products, tools, technologies, pets, places ("FastAPI", "Max the dog", "Paris")
    C = Concept — abstract noun phrases: approaches, topics, preferences ("microservices", "hiking", "jazz")
  Events (verb phrases — what HAPPENS or is CHOSEN):
    G = Goal     — what someone wants TO DO: "build REST API", "learn French", "visit Japan"
    D = Decision — what someone CHOSE: "use FastAPI", "adopted a dog", "moved to Austin"
    A = Action   — tasks, next steps, scheduled events: "migrate procedures", "starting school in September"
  Propositions (declarative clauses — what is TRUE):
    F = Fact     — quantitative data, personal details, preferences, opinions: "salary is $95k", "sister is a nurse", "loves hiking"

Edge types: uses, wants, chose, over, because, enables, blocks, next, relates, part_of, depends_on, conflicts, supersedes, knows, family, friend, colleague, prefers, likes, dislikes, lives_in, works_at, scheduled

CRITICAL RULES:
1. Extract EVERY technology/tool/framework/product by exact name
2. Extract EVERY person mentioned — include relationship, occupation, age, location in attrs
3. GOAL nodes: include goal_type, status, confidence in attrs
4. DECISION nodes: include rationale, confidence, context, trade_offs, alternatives, evidence, trigger
5. Extract ALL personal preferences, likes, dislikes, opinions, sentiments
6. Extract ALL events, activities, temporal facts with timeframe attrs
7. Extract ALL relationships (family, friends, colleagues, pets, professional)
8. OVER edges for alternatives: pros, cons, rejection_reason
9. BECAUSE edges: source (user_stated/inferred/external), confidence
10. FACT nodes: cover ALL factual statements — numeric AND non-numeric. Include value/unit/category for quantitative facts
11. Include turn_ref for temporal ordering
12. Preserve exact names, dates, quantities, and proper nouns

NUMERIC DATA PRESERVATION:
- ALWAYS preserve specific numbers in BOTH labels AND attrs
- Use attrs: count, quantity, percentage, amount, timeline, duration, value, unit, category, breakdown"""

# --- Comprehensive Full Extraction Prompt ---
EXTRACTION_PROMPT_COMPREHENSIVE = """Extract knowledge graph capturing ALL information — technical decisions, personal details, social relationships, and factual data — from this conversation:

{conversation}

Output JSON with nodes and edges covering EVERYTHING mentioned:
{{
  "n": [
    {{"id": "P1", "t": "P", "l": "user"}},
    {{"id": "P2", "t": "P", "l": "Alice", "a": {{"relationship": "sister", "occupation": "nurse"}}}},
    {{"id": "G1", "t": "G", "l": "build REST API", "a": {{"goal_type": "goal", "status": "active", "confidence": 0.9}}}},
    {{"id": "D1", "t": "D", "l": "use FastAPI", "a": {{"rationale": "better async performance", "confidence": 0.85, "alternatives": ["Django", "Flask"]}}}},
    {{"id": "T1", "t": "T", "l": "FastAPI"}},
    {{"id": "F1", "t": "F", "l": "user loves hiking", "a": {{"category": "preference", "sentiment": "positive"}}}},
    {{"id": "F2", "t": "F", "l": "annual salary is $95,000", "a": {{"value": 95000, "unit": "USD", "category": "income"}}}},
    {{"id": "F3", "t": "F", "l": "team has 8 backend engineers", "a": {{"count": 8, "breakdown": "5 Java, 2 Go, 1 Rust"}}}}
  ],
  "e": [
    {{"s": "P1", "d": "P2", "r": "family", "a": {{"type": "sibling"}}}},
    {{"s": "P1", "d": "G1", "r": "wants"}},
    {{"s": "D1", "d": "T1", "r": "chose"}},
    {{"s": "F1", "d": "P1", "r": "relates"}},
    {{"s": "F2", "d": "P1", "r": "relates"}}
  ]
}}

EXTRACTION RULES FOR COMPREHENSIVE:
1. Extract EVERY technology/tool/framework/language by EXACT name
2. Extract EVERY person mentioned by name — include relationship, occupation, age, location in attrs
3. GOAL nodes: Always include goal_type, status, confidence in attrs
4. DECISION nodes: Always include rationale, confidence, context, trade_offs, alternatives, evidence, trigger
5. OVER edges for alternatives: pros, cons, rejection_reason
6. BECAUSE edges: source, confidence
7. Extract ALL personal preferences, likes, dislikes, opinions as FACT nodes
8. Extract ALL events, activities, temporal facts with timeframe attrs
9. Extract ALL relationships (family, friends, colleagues, pets) as edges
10. FACT nodes: cover ALL factual statements — numeric AND non-numeric
11. For numeric data: capture in BOTH label AND attrs with value, unit, category
12. Include turn_ref for temporal ordering
13. Preserve exact names, dates, and quantities
14. Only output valid JSON — no extra text"""

# --- Comprehensive Delta Extraction Prompt ---
DELTA_PROMPT_COMPREHENSIVE = """Analyze new conversation content and extract ALL NEW information — technical, personal, and social — to add to the existing knowledge graph.

EXISTING NODES (do NOT repeat these, they already exist):
{existing_graph}

NEW CONVERSATION:
{new_messages}

Extract ONLY genuinely NEW information not already in the graph above.
Output JSON with:
- "add_n": New nodes (entities, goals, decisions, actions, facts, people, preferences) NOT in existing list
- "add_e": New relationships between nodes
- "update_n": Updates to existing node attributes (status changes, new data, updated values)

Format:
{{
  "add_n": [{{"id": "T1", "t": "T", "l": "NewTechnology", "a": {{}}}}, {{"id": "F1", "t": "F", "l": "prefers morning meetings", "a": {{"category": "preference"}}}}],
  "add_e": [{{"s": "G1", "d": "T1", "r": "uses"}}, {{"s": "P1", "d": "P3", "r": "colleague"}}],
  "update_n": [{{"id": "G1", "a": {{"status": "completed"}}}}, {{"id": "P2", "a": {{"occupation": "senior nurse"}}}}]
}}

GRAMMAR-AWARE NODE TYPES:
  Entities: P=Person, T=Thing, C=Concept
  Events:   G=Goal, D=Decision, A=Action
  Propositions: F=Fact
Edge types: uses, wants, chose, over, because, enables, blocks, next, relates, part_of, depends_on, conflicts, supersedes, knows, family, friend, colleague, prefers, likes, dislikes, lives_in, works_at, scheduled

COMPREHENSIVE EXTRACTION RULES:
1. Extract EVERY new technology/tool AND every new person mentioned
2. DECISION nodes: full rationale, alternatives, evidence, trigger
3. Extract ALL new personal preferences, opinions, sentiments
4. Extract ALL new events, activities, temporal facts
5. Extract ALL new relationships (professional, family, social)
6. FACT nodes: ALL new factual statements — numeric AND non-numeric
7. Label normalization: fix slang, preserve numbers and proper nouns
8. When values change, use update_n with as_of turn reference

CRITICAL - NUMERIC DATA PRESERVATION:
- ALWAYS capture specific numbers in BOTH labels AND attrs
- Use attrs: count, quantity, percentage, amount, timeline, duration, value, unit, category

Output valid JSON only - no explanations."""


# =============================================================================
# Profile Registry (Issue #139)
# =============================================================================

EXTRACTION_PROFILES = {
    "decision": {
        "system": UNIFIED_EXTRACTION_SYSTEM,
        "full_prompt": UNIFIED_EXTRACTION_PROMPT,
        "delta_prompt": DELTA_EXTRACTION_PROMPT,
    },
    "conversational": {
        "system": CONVERSATIONAL_EXTRACTION_SYSTEM,
        "full_prompt": EXTRACTION_PROMPT_CONVERSATIONAL,
        "delta_prompt": DELTA_PROMPT_CONVERSATIONAL,
    },
    "comprehensive": {
        "system": COMPREHENSIVE_EXTRACTION_SYSTEM,
        "full_prompt": EXTRACTION_PROMPT_COMPREHENSIVE,
        "delta_prompt": DELTA_PROMPT_COMPREHENSIVE,
    },
}

VALID_PROFILES = frozenset(EXTRACTION_PROFILES.keys())


# =============================================================================
# Unified Extractor
# =============================================================================

class UnifiedExtractor:
    """
    Single-pass extractor that captures all memory types.
    
    Replaces separate IntentTracker + ReasoningStore with one unified extraction.
    
    Token Efficiency:
    - Current: 6 calls × ~10K tokens = 60K tokens per conversation
    - Optimized: 1-2 calls × ~5K tokens = 5-10K tokens per conversation
    - Target: 10-20x overhead vs 220x current
    
    Usage:
        extractor = UnifiedExtractor(llm_client)
        
        # Full extraction (new conversation)
        graph = await extractor.extract(messages)
        
        # Delta extraction (continuation)
        graph = await extractor.extract_delta(new_messages, existing_graph)
    """
    
    def __init__(self, llm_client: LLMClient, grammar_validator=None, extraction_profile: str = "decision"):
        self.llm = llm_client
        self._extraction_cache: dict[str, UnifiedGraph] = {}
        # Issue #120: Optional grammar-aware validation and auto-correction
        self._grammar_validator = grammar_validator
        # Issue #139: Configurable extraction profiles
        if extraction_profile not in VALID_PROFILES:
            raise ValueError(
                f"Unknown extraction_profile '{extraction_profile}'. "
                f"Valid profiles: {', '.join(sorted(VALID_PROFILES))}"
            )
        self._profile_name = extraction_profile
        self._profile = EXTRACTION_PROFILES[extraction_profile]
        logger.info(f"Extraction profile: {extraction_profile}")
    
    def _format_conversation(self, messages: list[dict]) -> str:
        """Format messages compactly for prompt."""
        lines = []
        for i, msg in enumerate(messages):
            role = msg.get("role", "user")[0].upper()  # U or A
            content = msg.get("content", "")
            # Truncate very long messages
            if len(content) > 500:
                content = content[:500] + "..."
            lines.append(f"{role}: {content}")
        return "\n".join(lines)
    
    def _cache_key(self, messages: list[dict]) -> str:
        """Generate cache key for messages."""
        content = json.dumps(messages, sort_keys=True)
        return hashlib.sha256(content.encode()).hexdigest()[:12]
    
    async def extract(
        self,
        messages: list[dict],
        use_cache: bool = True
    ) -> UnifiedGraph:
        """
        Extract unified graph from conversation.
        
        Single LLM call captures:
        - Entities (WHO, WHAT)
        - Goals (objectives)
        - Decisions with rationale (WHY)
        - Relationships (CONNECTIONS)
        - Next actions (NEXT-STEPS)
        
        Args:
            messages: Conversation messages
            use_cache: Whether to use extraction cache
            
        Returns:
            UnifiedGraph with all extracted knowledge
        """
        # Check cache
        cache_key = self._cache_key(messages)
        if use_cache and cache_key in self._extraction_cache:
            logger.debug(f"Cache hit for extraction: {cache_key}")
            return self._extraction_cache[cache_key]
        
        # Format conversation
        conv_text = self._format_conversation(messages)
        
        # Single extraction call — use profile-specific prompt (Issue #139)
        prompt = self._profile["full_prompt"].format(conversation=conv_text)
        system = self._profile["system"]
        
        try:
            # Issue #133: Tell TieredLLMClient to classify on raw
            # conversation text, not the extraction template.
            if hasattr(self.llm, 'set_classify_text'):
                self.llm.set_classify_text(conv_text)
            response = await self.llm.complete(prompt, system=system)
            graph = self._parse_response(response)
        except Exception as e:
            logger.error(f"Extraction failed: {e}")
            graph = UnifiedGraph()
        
        # Issue #116: Infer goal statuses from graph edges
        graph.infer_goal_statuses()
        
        # Issue #120: Grammar validation and auto-correction
        if self._grammar_validator:
            self._grammar_validator.validate_and_correct(graph)
        
        # Cache result
        if use_cache:
            self._extraction_cache[cache_key] = graph
        
        return graph
    
    async def extract_delta(
        self,
        new_messages: list[dict],
        existing_graph: UnifiedGraph
    ) -> UnifiedGraph:
        """
        Extract only changes from new messages.
        
        More token-efficient for multi-turn conversations:
        - Sends compact representation of existing graph
        - Only extracts NEW information
        - Merges delta into existing graph
        - Applies attribute updates to existing nodes
        
        Args:
            new_messages: New messages to process
            existing_graph: Current graph state
            
        Returns:
            Updated UnifiedGraph
        """
        # Compact representation of existing graph
        existing_compact = self._compact_graph_repr(existing_graph)
        new_conv = self._format_conversation(new_messages)
        
        # Issue #139: Use profile-specific delta prompt
        prompt = self._profile["delta_prompt"].format(
            existing_graph=existing_compact,
            new_messages=new_conv
        )
        system = self._profile["system"]
        
        try:
            # Issue #133: Tell TieredLLMClient to classify on the raw
            # new-messages text, not the full delta extraction template.
            if hasattr(self.llm, 'set_classify_text'):
                self.llm.set_classify_text(new_conv)
            response = await self.llm.complete(prompt, system=system)
            delta, updates = self._parse_delta_response(response)
            
            # Merge delta into existing graph
            existing_graph.merge(delta)
            
            # Apply node updates (Issue #27 fix)
            for update in updates:
                node_id = update["id"]
                if node_id in existing_graph.nodes:
                    existing_graph.nodes[node_id].attrs.update(update["attrs"])
                    logger.debug(f"Updated node {node_id}: {update['attrs']}")
            
            # Issue #116: Infer goal statuses from graph edges
            existing_graph.infer_goal_statuses()
            
            # Issue #120: Grammar validation and auto-correction
            if self._grammar_validator:
                self._grammar_validator.validate_and_correct(existing_graph)
            
        except Exception as e:
            logger.error(f"Delta extraction failed: {e}")
        
        return existing_graph
    
    def _compact_graph_repr(self, graph: UnifiedGraph) -> str:
        """Create compact string representation of graph for prompt.
        
        Lists all existing nodes clearly so LLM knows what NOT to add.
        Includes key numeric attributes so LLM knows what data already exists.
        """
        lines = []
        
        # Numeric attribute keys to include in compact repr
        _NUMERIC_KEYS = {
            "count", "quantity", "percentage", "amount", "timeline",
            "duration", "metric", "target", "completed", "total",
            "breakdown", "target_date", "budget", "cost",
        }
        
        # Nodes by type - clear listing format with key numeric attrs
        for node_type in NodeType:
            nodes = [n for n in graph.nodes.values() if n.type == node_type]
            if nodes:
                parts = []
                for n in nodes:
                    # Include key numeric attrs inline
                    numeric_attrs = {
                        k: v for k, v in n.attrs.items()
                        if k in _NUMERIC_KEYS and v is not None
                    }
                    if numeric_attrs:
                        attr_str = ", ".join(f"{k}={v}" for k, v in numeric_attrs.items())
                        parts.append(f"{n.id}:{n.label} [{attr_str}]")
                    else:
                        parts.append(f"{n.id}:{n.label}")
                lines.append(f"{node_type.name}: {', '.join(parts)}")
        
        if not lines:
            lines.append("(empty graph - no existing nodes)")
        
        # Key edges (limit to most important)
        key_edges = [e for e in graph.edges 
                     if e.rel in (
                         EdgeType.CHOSE, EdgeType.BECAUSE, EdgeType.ENABLES,
                         EdgeType.DEPENDS_ON, EdgeType.CONFLICTS, 
                         EdgeType.BLOCKS, EdgeType.SUPERSEDES,
                     )][:15]
        if key_edges:
            edge_strs = [f"{e.src}->{e.rel.value}->{e.dst}" for e in key_edges]
            lines.append(f"KEY_EDGES: {'; '.join(edge_strs)}")
        
        return "\n".join(lines)
        
        return "\n".join(lines)
    
    def _parse_response(self, response: str) -> UnifiedGraph:
        """Parse LLM response into UnifiedGraph."""
        graph = UnifiedGraph()
        
        # Extract JSON from response
        try:
            # Try to find JSON in response
            json_start = response.find("{")
            json_end = response.rfind("}") + 1
            if json_start >= 0 and json_end > json_start:
                json_str = response[json_start:json_end]
                data = json.loads(json_str)
            else:
                logger.warning("No JSON found in response")
                return graph
        except json.JSONDecodeError as e:
            logger.error(f"JSON parse error: {e}")
            return graph
        
        # Parse nodes
        for node_data in data.get("n", []):
            try:
                # LLM may return attrs as string/list/null — ensure dict
                raw_attrs = node_data.get("a", {})
                attrs = raw_attrs if isinstance(raw_attrs, dict) else {}
                node = GraphNode(
                    id=node_data["id"],
                    type=NodeType(node_data["t"]),
                    label=node_data["l"],
                    attrs=attrs
                )
                graph.add_node(node)
            except (KeyError, ValueError) as e:
                logger.warning(f"Skipping invalid node: {e}")
        
        # Parse edges
        for edge_data in data.get("e", []):
            try:
                # LLM may return attrs as string/list/null — ensure dict
                raw_attrs = edge_data.get("a", {})
                attrs = raw_attrs if isinstance(raw_attrs, dict) else {}
                edge = GraphEdge(
                    src=edge_data["s"],
                    dst=edge_data["d"],
                    rel=EdgeType(edge_data["r"]),
                    attrs=attrs
                )
                graph.add_edge(edge)
            except (KeyError, ValueError) as e:
                logger.warning(f"Skipping invalid edge: {e}")
        
        graph.metadata["extracted_at"] = _utc_now().isoformat()
        
        return graph
    
    def _parse_delta_response(self, response: str) -> tuple[UnifiedGraph, list[dict]]:
        """Parse delta extraction response.
        
        Returns:
            Tuple of (graph with new nodes/edges, list of node updates)
        """
        graph = UnifiedGraph()
        updates = []
        
        try:
            json_start = response.find("{")
            json_end = response.rfind("}") + 1
            if json_start >= 0 and json_end > json_start:
                data = json.loads(response[json_start:json_end])
            else:
                return graph, updates
        except json.JSONDecodeError:
            return graph, updates
        
        # Parse new nodes
        for node_data in data.get("add_n", []):
            try:
                # LLM may return attrs as string/list/null — ensure dict
                raw_attrs = node_data.get("a", {})
                attrs = raw_attrs if isinstance(raw_attrs, dict) else {}
                node = GraphNode(
                    id=node_data["id"],
                    type=NodeType(node_data["t"]),
                    label=node_data["l"],
                    attrs=attrs
                )
                graph.add_node(node)
            except (KeyError, ValueError):
                pass
        
        # Parse new edges
        for edge_data in data.get("add_e", []):
            try:
                # LLM may return attrs as string/list/null — ensure dict
                raw_attrs = edge_data.get("a", {})
                attrs = raw_attrs if isinstance(raw_attrs, dict) else {}
                edge = GraphEdge(
                    src=edge_data["s"],
                    dst=edge_data["d"],
                    rel=EdgeType(edge_data["r"]),
                    attrs=attrs
                )
                graph.add_edge(edge)
            except (KeyError, ValueError):
                pass
        
        # Parse node updates (Issue #27 fix)
        for update_data in data.get("update_n", []):
            try:
                raw_attrs = update_data.get("a", {})
                attrs = raw_attrs if isinstance(raw_attrs, dict) else {}
                updates.append({
                    "id": update_data["id"],
                    "attrs": attrs
                })
            except KeyError:
                pass
        
        return graph, updates


# =============================================================================
# Extraction Statistics
# =============================================================================

@dataclass
class ExtractionStats:
    """Track extraction performance."""
    total_extractions: int = 0
    cache_hits: int = 0
    total_tokens: int = 0
    total_nodes: int = 0
    total_edges: int = 0
    
    @property
    def cache_hit_rate(self) -> float:
        if self.total_extractions == 0:
            return 0.0
        return self.cache_hits / self.total_extractions
    
    @property
    def avg_tokens_per_extraction(self) -> float:
        effective = self.total_extractions - self.cache_hits
        if effective == 0:
            return 0.0
        return self.total_tokens / effective


# =============================================================================
# Helper Functions
# =============================================================================

def graph_to_memories(graph: UnifiedGraph) -> list[dict]:
    """
    Convert UnifiedGraph to list of memory objects for compatibility.
    
    Each node becomes a memory, edges add relationship context.
    """
    memories = []
    
    for node in graph.nodes.values():
        memory = {
            "id": node.id,
            "type": node.type.name.lower(),
            "content": node.label,
            "attributes": node.attrs,
            "relationships": []
        }
        
        # Add relationship context
        for edge in graph.get_relationships_for(node.id):
            if edge.src == node.id:
                other = graph.nodes.get(edge.dst)
                if other:
                    memory["relationships"].append({
                        "type": edge.rel.value,
                        "target": other.label,
                        "direction": "outgoing"
                    })
            else:
                other = graph.nodes.get(edge.src)
                if other:
                    memory["relationships"].append({
                        "type": edge.rel.value,
                        "target": other.label,
                        "direction": "incoming"
                    })
        
        memories.append(memory)
    
    return memories


def memories_to_context_string(graph: UnifiedGraph, max_tokens: int = 2000) -> str:
    """
    Convert graph to context string for LLM prompt.
    
    Prioritizes:
    1. Active goals
    2. Recent decisions with rationale
    3. Key entities
    4. Next actions
    
    Issue #109: Includes numeric/quantitative attributes in output
    """
    # Attribute keys to surface for numeric data preservation
    _NUMERIC_ATTRS = {
        "count", "quantity", "percentage", "amount", "timeline",
        "duration", "metric", "target", "completed", "total",
        "breakdown", "target_date", "budget", "cost",
    }
    
    def _format_label(node) -> str:
        """Format node label with key numeric attributes."""
        label = node.label
        if not node.attrs:
            return label
        numeric_parts = []
        for key in sorted(node.attrs.keys()):
            if key in _NUMERIC_ATTRS:
                val = node.attrs[key]
                if val is not None and val != "" and val != []:
                    numeric_parts.append(f"{key}: {val}")
        if numeric_parts:
            return f"{label} ({', '.join(numeric_parts)})"
        return label
    
    sections = []
    
    # Goals — Issue #110: group by status so completed/abandoned are visible
    goals = graph.get_goals()
    if goals:
        active_goals = []
        completed_goals = []
        other_goals = []
        for g in goals:
            status = (g.attrs.get("status", "active") if g.attrs else "active").lower()
            if status in ("active", "pending"):
                active_goals.append(g)
            elif status == "completed":
                completed_goals.append(g)
            else:
                other_goals.append(g)
        
        goal_lines = []
        if active_goals:
            goal_lines.append("Active:")
            for g in active_goals[:8]:
                goal_lines.append(f"  - {_format_label(g)} [ACTIVE]")
        if completed_goals:
            goal_lines.append("Completed:")
            for g in completed_goals[:5]:
                goal_lines.append(f"  - {_format_label(g)} [COMPLETED]")
        if other_goals:
            for g in other_goals[:3]:
                status = (g.attrs.get("status", "unknown") if g.attrs else "unknown").upper()
                goal_lines.append(f"  - {_format_label(g)} [{status}]")
        
        if goal_lines:
            sections.append("GOALS:\n" + "\n".join(goal_lines))
    
    # Decisions with context
    decisions = graph.get_decisions()
    if decisions:
        decision_lines = []
        for d in decisions[:5]:
            ctx = graph.get_decision_context(d.id)
            line = f"- {_format_label(d)}"
            if ctx.get("reasons"):
                line += f" (because: {', '.join(ctx['reasons'][:2])})"
            if ctx.get("alternatives"):
                alts = [a["option"] for a in ctx["alternatives"][:2]]
                line += f" [over: {', '.join(alts)}]"
            decision_lines.append(line)
        sections.append("DECISIONS:\n" + "\n".join(decision_lines))
    
    # Key entities
    entities = graph.get_entities()
    if entities:
        entity_lines = [f"- {_format_label(e)} ({e.type.name})" for e in entities[:10]]
        sections.append("KEY ENTITIES:\n" + "\n".join(entity_lines))
    
    # Next actions
    actions = graph.get_actions()
    if actions:
        action_lines = [f"- {_format_label(a)}" for a in actions[:5]]
        sections.append("NEXT STEPS:\n" + "\n".join(action_lines))
    
    # Issue #111: Structural relationships (dependencies, blockers, conflicts)
    structural = graph.get_structural_edges()
    if structural:
        dep_lines = []
        blocker_lines = []
        conflict_lines = []
        supersede_lines = []
        for edge in structural:
            line = f"  - {edge['src_label']} → {edge['dst_label']}"
            if edge["rel"] == "depends_on":
                dep_lines.append(line)
            elif edge["rel"] == "blocks":
                blocker_lines.append(line)
            elif edge["rel"] == "conflicts":
                conflict_lines.append(line)
            elif edge["rel"] == "supersedes":
                supersede_lines.append(line)
        
        rel_parts = []
        if dep_lines:
            rel_parts.append("Dependencies:\n" + "\n".join(dep_lines[:10]))
        if blocker_lines:
            rel_parts.append("Blockers:\n" + "\n".join(blocker_lines[:5]))
        if conflict_lines:
            rel_parts.append("Conflicts:\n" + "\n".join(conflict_lines[:5]))
        if supersede_lines:
            rel_parts.append("Superseded:\n" + "\n".join(supersede_lines[:5]))
        
        if rel_parts:
            sections.append("RELATIONSHIPS:\n" + "\n".join(rel_parts))
    
    return "\n\n".join(sections)
