"""Knowledge graph — SPO triples + wiki-link extraction from vault files.

Two separate graphs:
1. Wiki-link graph (undirected): [[entities]] parsed from markdown files.
   Captures co-occurrence — "these things are mentioned together."
2. SPO graph (directed): structured subject-predicate-object facts with
   authority, epistemic status, decay. Captures meaning — "this relates to that how."

Both use the unified EntityRegistry as the canonical entity source.
Triples are validated against the Ontology schema at insertion time.
Contradiction detection prevents conflicting single-cardinality facts.
"""

from __future__ import annotations

import json
import logging
import os
import re
import time
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any

import networkx as nx

from agntspace.memory.build_index import (
    BuildIndex,
    FileContribution,
    FileStat,
)
from agntspace.memory.entity_registry import EntityRegistry, _slugify
from agntspace.memory.ontology import (
    AUTHORITY_WEIGHTS,
    EntityType,
    EpistemicStatus,
    build_extraction_prompt,
    get_inferences,
    get_inverse,
    get_knowledge_type,
    is_single_cardinality,
    load_ontology,
    resolve_entity_type,
    validate_triple,
)
from agntspace.memory.triple_index import TripleIndex

if TYPE_CHECKING:
    from agntspace.llm.base import LLMProvider
    from agntspace.vault.reader import VaultReader
    from agntspace.vault.writer import VaultWriter

log = logging.getLogger(__name__)

WIKI_LINK_PATTERN = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]")

# AUTHORITY_WEIGHTS is imported from ontology (backwards compat)

_TRIPLES_PATH = "memory/graph/triples.json"
_DISMISSED_MERGES_PATH = "memory/graph/dismissed_merges.json"
_BUILD_INDEX_PATH = "memory/graph/build_index.json"  # Phase 3 — see tasks/plan-incremental-graph-build.md
_WIKILINK_INDEX_PATH = "memory/graph/wikilink_index.json"  # Phase 4 — per-file wiki-link snapshot
_WIKILINK_INDEX_SCHEMA_VERSION = 1

# ---------------------------------------------------------------------------
# Memory policy — loaded from the memory-consolidate skill config.
#
# Rule 12 Phase 3 migration (2026-04-11): the previously-hardcoded
# constants ``_PATH_ETYPE_RULES``, ``_LINK_BLOCKLIST``,
# ``_KNOWLEDGE_TYPE_HALF_LIVES``, ``_MIN_QUERY_WEIGHT``, and
# ``_MAX_CASCADE_DEPTH`` have all moved to YAML files under
# ``defaults/skills/core/memory-consolidate/config/``:
#
#   decay.yaml       — knowledge_type_half_lives
#   inference.yaml   — path_to_type rules
#   graph-scan.yaml  — link_blocklist
#   retrieval.yaml   — min_query_weight, max_cascade_depth
#
# The KnowledgeGraph instance loads these via its injected
# ``SkillLoader`` (see ``__init__``). A module-level cache ensures
# we read the YAML at most once per process unless the cache is
# explicitly invalidated. The fallback values below are the
# degraded-mode safety nets used only when the skill is missing —
# they mirror the YAML defaults so behavior degrades gracefully
# rather than crashing.
# ---------------------------------------------------------------------------

# Degraded-mode safety nets — used only when the memory-consolidate skill
# is unavailable (no SkillLoader injected, missing config file, malformed
# YAML). The YAML files are the real strategy. See Rule 12.
_FALLBACK_HALF_LIVES: dict[str, int] = {  # arch:deterministic — degraded-mode mirror of decay.yaml; never consulted when the skill config is present, see Rule 12
    "episodic": 30,
    "declarative": 90,
    "procedural": 180,
    "normative": 365,
    "intentional": 120,
}
_FALLBACK_LINK_BLOCKLIST: frozenset[str] = frozenset({  # arch:deterministic — degraded-mode mirror of graph-scan.yaml link_blocklist; structural section labels and template placeholders that exist in every English wiki vault regardless of user preference
    "sources", "links", "backlinks", "link", "source", "references",
    "todo", "note", "see also", "index", "catalog", "contents",
    "open questions", "counter-arguments", "data gaps",
    "wiki-links", "wikilinks", "wiki-link", "wikilink",
    "title", "example", "slug",
})
_FALLBACK_PATH_ETYPE_RULES: tuple[tuple[str, str], ...] = (  # arch:deterministic — degraded-mode mirror of inference.yaml path_to_type; the two structural rules built into every vault layout
    ("system/agents/", "ai_agent"),
    ("system/skills/", "skill"),
)
_FALLBACK_MIN_QUERY_WEIGHT: float = 0.05  # arch:deterministic — degraded-mode mirror of retrieval.yaml min_query_weight; structural floor on noise rejection
_FALLBACK_MAX_CASCADE_DEPTH: int = 20  # arch:deterministic — degraded-mode mirror of retrieval.yaml max_cascade_depth; structural BFS limit to prevent runaway cascades


# Module-level loader cache for the memory policy. None means "not yet
# loaded". A dict means "loaded; reuse". Invalidated by
# ``invalidate_memory_policy_cache()`` for tests and skill hot-reload.
_memory_policy_cache: dict | None = None
# Module-level reference to the SkillLoader to use when reading the policy.
# Set by KnowledgeGraph.__init__ when constructed with a loader. The
# module-level scope is intentional — multiple KnowledgeGraph instances
# in the same process (rare but possible in tests) all share one cache.
_memory_policy_loader: object | None = None


def _set_memory_policy_loader(loader: object | None) -> None:
    """Install a SkillLoader for module-level memory policy resolution.

    Called by ``KnowledgeGraph.__init__`` so that the module-level
    helpers (``_etype_from_path``, ``_link_blocklist``, etc.) can
    resolve their data without needing the instance. This is the
    bridge between Rule 12 (skills drive strategy) and the existing
    free-function API of this module.
    """
    global _memory_policy_loader, _memory_policy_cache
    _memory_policy_loader = loader
    _memory_policy_cache = None  # any new loader invalidates the old cache


def invalidate_memory_policy_cache() -> None:
    """Force re-read of memory-consolidate config YAMLs on next access.

    Tests call this between cases to avoid cross-test bleed. The skill
    hot-reload path may call this in the future when SKILL.md mtime
    changes are detected (currently a manual call).
    """
    global _memory_policy_cache
    _memory_policy_cache = None


def _load_memory_policy() -> dict:
    """Load and flatten the four memory-consolidate config YAMLs.

    Returns a dict shaped::

        {
            "half_lives": {episodic: 30, declarative: 90, ...},
            "link_blocklist": frozenset({"sources", ...}),
            "path_etype_rules": [(prefix, type), ...],
            "min_query_weight": 0.05,
            "max_cascade_depth": 20,
        }

    Cached at module level for the lifetime of the process (or until
    ``invalidate_memory_policy_cache()`` is called). When the loader
    or any individual config file is missing, the corresponding key
    falls back to ``_FALLBACK_*`` — the engine never crashes on a
    missing skill, it just runs in degraded mode with the same
    structural defaults the YAML would have provided.
    """
    global _memory_policy_cache
    if _memory_policy_cache is not None:
        return _memory_policy_cache

    policy: dict = {
        "half_lives": dict(_FALLBACK_HALF_LIVES),
        "link_blocklist": _FALLBACK_LINK_BLOCKLIST,
        "path_etype_rules": list(_FALLBACK_PATH_ETYPE_RULES),
        "min_query_weight": _FALLBACK_MIN_QUERY_WEIGHT,
        "max_cascade_depth": _FALLBACK_MAX_CASCADE_DEPTH,
    }

    loader = _memory_policy_loader
    if loader is None or not hasattr(loader, "load_skill_config_file"):
        _memory_policy_cache = policy
        return policy

    # decay.yaml — half lives
    try:
        decay = loader.load_skill_config_file("memory-consolidate", "decay.yaml")
        if isinstance(decay, dict):
            half_lives = decay.get("knowledge_type_half_lives")
            if isinstance(half_lives, dict):
                cleaned = {
                    str(k): int(v) for k, v in half_lives.items()
                    if isinstance(v, (int, float)) and v > 0
                }
                if cleaned:
                    policy["half_lives"] = cleaned
    except Exception:
        log.debug("memory.graph: decay.yaml load failed", exc_info=True)

    # inference.yaml — path → type rules
    try:
        inf = loader.load_skill_config_file("memory-consolidate", "inference.yaml")
        if isinstance(inf, dict):
            rules = inf.get("path_to_type")
            if isinstance(rules, list):
                parsed: list[tuple[str, str]] = []
                for item in rules:
                    if isinstance(item, dict):
                        sub = item.get("substring")
                        et = item.get("entity_type")
                        if isinstance(sub, str) and isinstance(et, str):
                            parsed.append((sub, et))
                if parsed:
                    policy["path_etype_rules"] = parsed
    except Exception:
        log.debug("memory.graph: inference.yaml load failed", exc_info=True)

    # graph-scan.yaml — link blocklist (multilingual flatten)
    try:
        scan = loader.load_skill_config_file("memory-consolidate", "graph-scan.yaml")
        if isinstance(scan, dict):
            block = scan.get("link_blocklist")
            collected: set[str] = set()
            if isinstance(block, dict):
                for _lang, items in block.items():
                    if isinstance(items, list):
                        for w in items:
                            if isinstance(w, str) and w.strip():
                                collected.add(w.strip().lower())
            elif isinstance(block, list):
                for w in block:
                    if isinstance(w, str) and w.strip():
                        collected.add(w.strip().lower())
            if collected:
                policy["link_blocklist"] = frozenset(collected)
    except Exception:
        log.debug("memory.graph: graph-scan.yaml load failed", exc_info=True)

    # retrieval.yaml — query weight + cascade depth
    try:
        retr = loader.load_skill_config_file("memory-consolidate", "retrieval.yaml")
        if isinstance(retr, dict):
            mqw = retr.get("min_query_weight")
            if isinstance(mqw, (int, float)) and 0.0 <= mqw <= 1.0:
                policy["min_query_weight"] = float(mqw)
            mcd = retr.get("max_cascade_depth")
            if isinstance(mcd, int) and mcd > 0:
                policy["max_cascade_depth"] = mcd
    except Exception:
        log.debug("memory.graph: retrieval.yaml load failed", exc_info=True)

    _memory_policy_cache = policy
    return policy


def _etype_from_path(relative_path: str) -> str:
    """Infer entity_type from a vault-relative path. Returns '' if no rule matches.

    Two pass:
      1. Hardcoded vault-layout rules (PROJECT.md, goals/, projects/.../tasks/,
         wiki/events|places|decisions|documents|people|entities). These are
         structural invariants of the vault layout and cannot be overridden
         without breaking the layout itself — they're marked deterministic.
      2. Substring rules from the memory-consolidate inference.yaml skill
         config. User-extensible: add custom prefix-to-type mappings without
         touching code.
    """
    basename = relative_path.rsplit("/", 1)[-1]
    # Project containers
    if basename == "PROJECT.md":
        return "project"
    # Top-level goals/ directory
    if relative_path.startswith("goals/"):
        return "goal"
    # Project-scoped tasks live at projects/{slug}/tasks/*.md
    if relative_path.startswith("projects/") and "/tasks/" in relative_path:
        return "task"
    # Wiki subdir fallbacks (when LLM writes to a typed subdir without
    # frontmatter entity_type). Ordered most-specific first.
    if "/wiki/events/" in relative_path:
        return "event"
    if "/wiki/places/" in relative_path:
        return "place"
    if "/wiki/decisions/" in relative_path:
        return "decision"
    if "/wiki/documents/" in relative_path:
        return "document"
    if "/wiki/people/" in relative_path or "/wiki/entities/" in relative_path:
        # Entities subdir is mixed (person + org) — leave blank so frontmatter wins.
        # People subdir is unambiguous.
        if "/wiki/people/" in relative_path:
            return "person"
    # User-extensible substring rules from inference.yaml
    for prefix, etype in _load_memory_policy()["path_etype_rules"]:
        if prefix in relative_path:
            return etype
    return ""


def _build_triple_extract_prompt() -> str:
    """Build the triple extraction prompt dynamically from the loaded ontology."""
    vocab = build_extraction_prompt()
    return f"""You are a knowledge graph extraction engine. Extract structured facts as subject-predicate-object triples from the text below.

Return a JSON array of triples. Each triple:
```json
[
  {{
    "subject": "entity name (person, company, project, concept)",
    "subject_type": "person|organization|project|concept|place|event|task|goal|decision|document",
    "predicate": "relationship verb",
    "object": "entity name or literal value",
    "object_type": "person|organization|project|concept|place|event|task|goal|decision|document|literal",
    "confidence": 0.9,
    "knowledge_type": "declarative|episodic|procedural|normative|intentional"
  }}
]
```

{vocab}

Knowledge types:
- declarative: facts ("Sarah works at Acme")
- episodic: events ("Met Sarah on April 5")
- procedural: how-to ("To deploy, run make deploy")
- normative: preferences/rules ("We prefer PostgreSQL")
- intentional: goals/plans ("Launch by Q3")

Confidence scale:
- 1.0 = explicitly stated fact
- 0.7-0.9 = strongly implied
- 0.5-0.7 = inferred from context

Rules:
- Extract ONLY facts present in the text. Never invent.
- Prefer specific predicates over generic "related_to".
- Use full entity names ("Sarah Chen" not "Sarah").
- For literal values (dates, statuses), set object_type to "literal".
- Maximum 30 triples per extraction.
- Return ONLY the JSON array, no other text."""


def _now_iso() -> str:
    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


def _today_iso() -> str:
    return datetime.now(UTC).strftime("%Y-%m-%d")


# ── Entity name quality gate ──────────────────────────────────────────
_FALLBACK_LIMITS = {  # arch:deterministic — degraded-mode defaults when junk-patterns.yaml is missing
    "max_chars": 60,
    "min_chars": 2,
    "max_words": 6,
    "reject_trailing_period": True,
}

# Two cache slots: one for when a skill_loader is present (real YAML),
# one for when it's absent (fallback limits).  Prevents cross-test
# contamination where a loader-equipped test poisons the cache for
# loader-less tests (or vice versa).
_junk_cache_with_loader: dict | None = None
_junk_cache_no_loader: dict | None = None


def _load_junk_patterns(skill_loader: object | None = None) -> dict:
    """Load and cache junk-patterns.yaml from kb-ingest skill config."""
    global _junk_cache_with_loader, _junk_cache_no_loader

    if skill_loader is None:
        if _junk_cache_no_loader is not None:
            return _junk_cache_no_loader
        patterns: dict = {
            "exact_names": set(),
            "substrings": tuple(),
            "leading_tokens": tuple(),
            "limits": dict(_FALLBACK_LIMITS),
        }
        _junk_cache_no_loader = patterns
        return patterns

    if _junk_cache_with_loader is not None:
        return _junk_cache_with_loader

    patterns = {
        "exact_names": set(),
        "substrings": tuple(),
        "leading_tokens": tuple(),
        "limits": dict(_FALLBACK_LIMITS),
    }

    def _flatten(section: object) -> list[str]:
        out: list[str] = []
        if isinstance(section, dict):
            for _lang, words in section.items():
                if isinstance(words, list):
                    for w in words:
                        if isinstance(w, str) and w.strip():
                            out.append(w.strip().lower())
        elif isinstance(section, list):
            for w in section:
                if isinstance(w, str) and w.strip():
                    out.append(w.strip().lower())
        return out

    try:
        raw = skill_loader.load_skill_config_file(
            "kb-ingest", "junk-patterns.yaml"
        )
    except Exception:
        log.debug("graph: junk-patterns.yaml load failed", exc_info=True)
        _junk_cache_with_loader = patterns
        return patterns

    if not isinstance(raw, dict):
        _junk_cache_with_loader = patterns
        return patterns

    patterns["exact_names"] = set(_flatten(raw.get("exact_names")))
    patterns["substrings"] = tuple(_flatten(raw.get("substrings")))
    patterns["leading_tokens"] = tuple(_flatten(raw.get("leading_tokens")))

    yaml_limits = raw.get("limits")
    if isinstance(yaml_limits, dict):
        for key in ("max_chars", "min_chars", "max_words", "reject_trailing_period"):
            if key in yaml_limits:
                patterns["limits"][key] = yaml_limits[key]

    _junk_cache_with_loader = patterns
    return patterns


def is_junk_entity_name(name: str, *, skill_loader: object | None = None) -> bool:
    """Return True if *name* is structurally invalid as an entity name.

    This is the universal quality gate called from ``add_triple()`` so
    every call site is protected.  It enforces **structural** rules only:

    - Empty / None / whitespace-only
    - Bracket-wrapped placeholders ``[Your name]``
    - Exact placeholder matches from YAML (none, n/a, tbd, etc.)
    - Over 60 chars or 6+ words (sentence fragments, not names)

    It deliberately does NOT enforce stylistic rules (leading articles,
    substring matches) — those are quality-tier filters that belong in
    ``KBService._is_junk_name()`` where ingest standards are higher.
    """
    if not name or not isinstance(name, str):
        return True
    lower = name.strip().lower()
    if not lower:
        return True

    # Bracket-wrapped placeholders: [Your name], [TODO], [placeholder]
    if lower.startswith("[") and lower.endswith("]"):
        return True

    patterns = _load_junk_patterns(skill_loader)
    limits = patterns["limits"]

    # Exact matches (multilingual placeholders: none, n/a, tbd, etc.)
    if lower in patterns["exact_names"]:
        return True

    # Length caps — prevent sentence fragments from becoming entities.
    max_chars = limits.get("max_chars", 60)
    if isinstance(max_chars, int) and max_chars > 0 and len(lower) > max_chars:
        return True

    max_words = limits.get("max_words", 6)
    if isinstance(max_words, int) and max_words > 0 and len(lower.split()) > max_words:
        return True

    return False


class KnowledgeGraph:
    """Builds and queries a knowledge graph from wiki-links and SPO triples.

    Uses:
    - EntityRegistry for all entity operations (single source of truth)
    - Ontology for predicate validation and schema enforcement
    - Two separate NetworkX graphs: wiki-links (undirected) and SPO (directed)
    """

    def __init__(
        self,
        vault: VaultReader,
        writer: VaultWriter | None = None,
        llm: LLMProvider | None = None,
        entity_registry: EntityRegistry | None = None,
        skill_loader: object | None = None,
    ) -> None:
        self._vault = vault
        self._writer = writer
        self._llm = llm
        self._agent: object | None = None  # injected post-hoc from main.py
        self._skill_loader = skill_loader
        # Phase 6D: optional embedder for rerank_by_attention and
        # resolve_by_description. Injected post-hoc from main.py
        # as ``knowledge_graph._embedder = vault_embeddings._get_model()``.
        # When set, query paths can use it automatically without every
        # caller having to pass the embedder parameter.
        self._embedder: object | None = None
        # Wire the module-level memory policy loader so the free
        # functions (_etype_from_path, _load_memory_policy) can read
        # the memory-consolidate skill config without needing the
        # instance. Multiple KnowledgeGraph instances in one process
        # (rare but possible in tests) all share one cache; the last
        # constructor wins, which is fine because all instances in
        # the same process should be reading the same skill tree.
        if skill_loader is not None:
            _set_memory_policy_loader(skill_loader)

        # Entity registry (shared, canonical)
        if entity_registry:
            self._registry = entity_registry
        else:
            self._registry = EntityRegistry(vault=vault, writer=writer)

        # Wiki-link graph: undirected, co-occurrence
        self._wikilink_graph = nx.Graph()
        # SPO graph: directed, semantic relationships
        self._spo_graph = nx.DiGraph()
        # Combined view for backwards-compat queries
        self._graph = nx.Graph()

        self._node_sources: dict[str, set[str]] = {}

        # Cross-reference indexes (built during build())
        self._article_triples: dict[str, list[int]] = {}  # article path -> triple IDs
        self._article_entities: dict[str, set[str]] = {}  # article path -> entity slugs

        # Phase 4 — wiki-link snapshot persisted to memory/graph/wikilink_index.json.
        # Per-file dict: ``{relative_path: {label, entity_type, wiki_links: [raw text]}}``.
        # Stores RAW link text (the contents inside ``[[...]]``), not resolved
        # slugs, so renames + registry growth are picked up on each load.
        # Loaded by _load_graph_data, projected onto graphs by
        # _project_wikilink_index in incremental mode, mutated by
        # _scan_files_full per file. Saved by save() when dirty.
        self._wikilink_index: dict[str, dict] = {}
        self._wikilink_index_dirty: bool = False

        # Phase 6 — last-build observability snapshot. Populated by build()
        # at exit; surfaced via GET /api/graph/stats so operators can see
        # whether the most recent build was incremental or full and how
        # much work it actually did. Empty until first build() completes.
        # Shape (after build):
        #   {
        #     "mode": "incremental" | "full",
        #     "started_at": ISO-8601,
        #     "completed_at": ISO-8601,
        #     "duration_seconds": float,
        #     "files_added": int,
        #     "files_modified": int,
        #     "files_deleted": int,
        #     "files_unchanged": int,
        #     "files_processed": int,
        #     "edges_created": int,
        #     "total_indexed_files": int,
        #   }
        self._last_build_summary: dict = {}

        # SPO triples (loaded from JSON, kept in memory)
        self._triples: list[dict] = []
        self._next_triple_id: int = 1
        self._triples_dirty = False

        # Dedup index: (subject_slug, predicate, object_slug_or_literal) -> triple index
        # Turns O(n) dedup scan into O(1) lookup.
        self._dedup_index: dict[tuple, int] = {}

        # Multi-column index over the triple list. Provides O(1)
        # lookups by subject, object, layer, episode, prediction
        # outcome, and status. Rebuilt from the canonical JSON on
        # every build() call and maintained incrementally on every
        # add_triple / status mutation. Replaces the 35 O(n) linear
        # scans that would become the bottleneck at 100K+ triples.
        self._index = TripleIndex()

        # Fix #7: Extraction quality metrics — tracks LLM extraction health
        self._extraction_metrics: dict[str, Any] = {
            "total_extractions": 0,
            "total_triples_extracted": 0,
            "failed_extractions": 0,   # LLM returned invalid JSON or empty
            "empty_extractions": 0,    # LLM returned 0 triples from non-empty text
            "hallucinated_entities": 0, # Entities not found in source text
            "predicate_distribution": {},  # predicate -> count
            "knowledge_type_distribution": {},  # kt -> count
        }

    @property
    def registry(self) -> EntityRegistry:
        """Access the entity registry."""
        return self._registry

    # ── Backwards-compat properties ──

    @property
    def _entities(self) -> dict[str, dict]:
        """Backwards compat: delegate to registry."""
        return self._registry.entities

    @property
    def _dirty(self) -> bool:
        """Dirty if either triples or entities have unsaved changes."""
        return self._triples_dirty or self._registry.dirty

    @_dirty.setter
    def _dirty(self, value: bool) -> None:
        self._triples_dirty = value
        if not value:
            self._registry.dirty = False

    # ── File persistence ──

    def _load_graph_data(self) -> None:
        """Load entities, triples, and ontology from vault files."""
        # Load ontology from skills config (or fallback to defaults)
        skills_dir = self._vault.paths.resolve("system/skills") if self._vault.paths else self._vault.vault_dir / "system" / "skills"
        load_ontology(str(skills_dir) if skills_dir.exists() else None)

        self._registry.load()

        # Phase 4: load the wiki-link snapshot. Fail-soft — if it's missing
        # / corrupt / version-mismatched, _wikilink_index stays empty and
        # the incremental branch in build() falls back to a full scan.
        self._load_wikilink_index()

        # Load triples
        if self._vault.exists(_TRIPLES_PATH):
            try:
                path = self._vault.paths.resolve(_TRIPLES_PATH) if self._vault.paths else self._vault._vault_dir / _TRIPLES_PATH
                raw = path.read_text(encoding="utf-8")
                self._triples = json.loads(raw)
                if self._triples:
                    self._next_triple_id = max(t.get("id", 0) for t in self._triples) + 1
                # Migrate: add fields if missing from older data. The
                # entire migration is idempotent — running it twice on the
                # same triples is a no-op. New fields default to values
                # that preserve pre-migration semantics exactly.
                for t in self._triples:
                    if "epistemic_status" not in t:
                        t["epistemic_status"] = EpistemicStatus.BELIEVED.value
                    if "knowledge_type" not in t:
                        t["knowledge_type"] = get_knowledge_type(t.get("predicate", "")).value
                    # Epistemic fix: last_verified tracks when fact was confirmed true
                    # (distinct from last_accessed which tracks when it was read)
                    if "last_verified" not in t:
                        if t.get("epistemic_status") == "verified" or t.get("authority") == "explicit":
                            t["last_verified"] = t.get("source_date") or t.get("first_seen")
                            if not t["last_verified"]:
                                log.warning("Triple #%d marked verified but has no provenance date", t.get("id", "?"))
                        else:
                            t["last_verified"] = None
                    # Invariant: verified facts must have last_verified set
                    if t.get("epistemic_status") == "verified" and not t.get("last_verified"):
                        t["last_verified"] = t.get("source_date") or t.get("first_seen") or _now_iso()
                        log.warning("Triple #%d: verified but last_verified was None, auto-set", t.get("id", "?"))
                    # Epistemic fix: justified_by tracks inference dependencies
                    if "justified_by" not in t:
                        t["justified_by"] = []
                    # ── Phase 6 (CLS) field migration ──
                    # Existing triples are all "neocortical" by definition:
                    # they were extracted from sources/conversations and
                    # written to the long-term semantic graph. Hippocampal
                    # and counterfactual layers are new with Phase 6 and
                    # only get populated by the dream phases going forward.
                    if "system_layer" not in t:
                        t["system_layer"] = "neocortical"
                    if "episode_id" not in t:
                        # Pre-Phase-6 triples have no episode_id. They're
                        # implicitly part of "the historical episode" — the
                        # dream phases will treat them as already-consolidated
                        # knowledge that doesn't need re-replay.
                        t["episode_id"] = None
                    if "situational_context" not in t:
                        # Empty dict, not None — downstream code can do
                        # ``triple["situational_context"].get("mood")`` without
                        # a None check.
                        t["situational_context"] = {}
                    if "affective_weight" not in t:
                        # Neutral. Migration is conservative — we don't
                        # retroactively assign affect to historical facts.
                        # New extractions can set this; the old ones are
                        # treated as 0.0 (no affective boost or damping).
                        t["affective_weight"] = 0.0
                    if "prediction_outcome" not in t:
                        # No prediction was tracked for pre-Phase-6 triples.
                        # The prediction loop only annotates triples it
                        # actually predicted on.
                        t["prediction_outcome"] = None
                    # ── Bayesian confidence migration (Phase 5B) ──
                    # Convert fixed confidence score to Beta(α, β) posterior.
                    # Evidence strength based on authority tier.
                    if "conf_alpha" not in t:
                        _conf = t.get("confidence", 0.8)
                        _auth = t.get("authority", "inferred")
                        _ev = {"explicit": 20.0, "system": 10.0, "inferred": 5.0}.get(_auth, 5.0)  # arch:deterministic — prior evidence per authority
                        t["conf_alpha"] = max(0.01, _conf * _ev)
                        t["conf_beta"] = max(0.01, (1.0 - _conf) * _ev)
                    if "co_recall_count" not in t:
                        # Hebbian counter starts at 0. Will be incremented
                        # by future query responses that return this
                        # triple alongside others. See Phase 6D.
                        t["co_recall_count"] = 0
                    # ── Phase 7 bi-temporal field migration (2026-04-24) ──
                    # Backfill `t_created` from the best available proxy
                    # (source_date > valid_from > first_seen) so pre-Phase-7
                    # triples have a usable system-time creation. Missing
                    # proxies remain None — system-time queries treat None
                    # t_created as "unknown, include" (permissive) so no
                    # legacy row becomes invisible.
                    if "t_created" not in t:
                        t["t_created"] = (
                            t.get("source_date")
                            or t.get("valid_from")
                            or t.get("first_seen")
                            or None
                        )
                    # t_expired: set for triples already marked superseded
                    # or retracted at migration time. Active triples remain
                    # None (still-believed by our system).
                    if "t_expired" not in t:
                        _st = t.get("status", "active")
                        _eps = t.get("epistemic_status", "")
                        if _st in ("superseded", "retracted") or _eps in ("superseded", "retracted"):
                            t["t_expired"] = (
                                t.get("superseded_at")
                                or t.get("valid_until")
                                or _now_iso()
                            )
                        else:
                            t["t_expired"] = None
                # Build dedup index from loaded triples
                self._dedup_index.clear()
                for idx, t in enumerate(self._triples):
                    if t["status"] == "active":
                        key = (t["subject"], t["predicate"], t.get("object") or t.get("object_literal"))
                        self._dedup_index[key] = idx

                # Build the multi-column index for O(1) lookups.
                # This replaces the 35 O(n) linear scans that would
                # become the bottleneck at 100K+ triples.
                self._index.rebuild(self._triples)

                log.debug("Loaded %d triples from graph (index built)", len(self._triples))
            except Exception as e:
                log.warning("Failed to load triples.json: %s", e)
                self._triples = []

    def save(self) -> None:
        """Persist current graph state to vault JSON files."""
        if not self._writer:
            return

        # Save entities via registry
        self._registry.save()

        # Save triples
        if self._triples_dirty:
            triples_json = json.dumps(self._triples, separators=(",", ":"), ensure_ascii=False)
            # Internal bookkeeping write — see entity_registry.save() for
            # the rationale. Same `graph_persist` label so both halves of
            # the graph state share one audit string.
            self._writer.write(_TRIPLES_PATH, triples_json, trust_reason="graph_persist")
            self._triples_dirty = False
            log.info("Graph saved: %d entities, %d triples",
                     len(self._registry.entities), len(self._triples))

        # Phase 4: persist the wiki-link snapshot if it was mutated.
        # No-op when the dirty flag is clean. Bypasses VaultWriter and
        # writes raw JSON via Path.replace() — same atomic pattern used
        # by build_index.json.
        self._save_wikilink_index()

    # ── Entity management (delegated to registry) ──

    def add_entity(
        self,
        name: str,
        entity_type: str = "concept",
        authority: str = "inferred",
        aliases: list[str] | None = None,
        decay_condition: str = "when:unaccessed:90d",
    ) -> str:
        """Add or update an entity via the registry. Returns slug."""
        return self._registry.add(
            name=name,
            entity_type=entity_type,
            authority=authority,
            aliases=aliases,
            decay_condition=decay_condition,
        )

    def _resolve_entity(self, name: str) -> str | None:
        """Resolve entity name to slug via registry."""
        return self._registry.resolve(name)

    def _entity_name(self, slug: str | None) -> str:
        """Get display name for a slug."""
        return self._registry.get_name(slug)

    # ── Triple management ──

    def add_triple(
        self,
        subject: str,
        predicate: str,
        obj: str,
        authority: str = "inferred",
        source_file: str = "",
        source_date: str = "",
        confidence: float = 0.8,
        decay_condition: str = "when:superseded",
        subject_type: str = "concept",
        object_type: str = "concept",
        epistemic_status: str = "believed",
        knowledge_type: str | None = None,
        valid_from: str | None = None,
        valid_until: str | None = None,
        # ── Phase 6 (CLS / Memory Dreaming) additions ──
        # All optional with defaults that preserve pre-Phase-6 behavior.
        # See ``Phase 6 Foundation`` block in the module docstring for the
        # full architectural rationale (Complementary Learning Systems +
        # episodic richness + affective weighting + prediction loop +
        # Hebbian co-recall).
        system_layer: str = "neocortical",
        episode_id: str | None = None,
        situational_context: dict | None = None,
        affective_weight: float = 0.0,
        prediction_outcome: str | None = None,
    ) -> int:
        """Add a triple with ontology validation and contradiction detection.

        Returns the triple ID. Logs warnings for schema violations but still
        stores the triple (permissive — we don't want to lose data).

        **Phase 6 CLS-aware fields** (all optional, all default to behavior
        equivalent to pre-Phase-6 ingest paths):

          - ``system_layer`` — ``"hippocampal"`` (episodic, fast-decay,
            high-resolution recent experience), ``"neocortical"``
            (semantic, slow-decay, statistical generalization, the default
            for backwards compatibility), or ``"counterfactual"`` (paired
            with ``epistemic_status="rejected_hypothesis"`` for the
            considered-and-rejected trail). The Phase 6 dream phases use
            this field to route between layers; existing call sites that
            don't pass it land in the neocortex, which is exactly what
            we want for non-dream extractions.

          - ``episode_id`` — opaque identifier linking all triples extracted
            from the same chat turn / journal entry / source ingest.
            Hippocampal triples MUST have an episode_id (the dream phases
            use it to walk all-facts-from-this-moment); neocortical and
            counterfactual triples may have one if they were derived from
            a single episode. Format: ``ep-<iso8601>-<short_hash>``.

          - ``situational_context`` — dict of episodic texture for
            hippocampal recall. Suggested keys: ``who_was_present``,
            ``mood``, ``surprise_level``, ``source_quote``, ``time_of_day``,
            ``surrounding_topics``. The schema is intentionally loose
            because situational context is high-dimensional and the
            extraction skill (memory-dream-light) is what shapes it.

          - ``affective_weight`` — float in [0.0, 1.0] for emotional /
            personal salience independent of epistemic confidence. Multiplies
            into effective query weight so emotionally salient facts decay
            slower and surface more readily. Default 0.0 = neutral.

          - ``prediction_outcome`` — ``"confirmed"`` if a prior prediction
            about this fact was matched by incoming evidence,
            ``"surprising"`` if it contradicted a prediction, ``None`` if
            no prediction loop ran. Used by the prediction loop to weight
            belief revision (confirmed → boost confidence; surprising →
            flag for review).
        """
        now = _now_iso()
        is_literal = object_type == "literal"

        # Bounds check on numeric fields
        confidence = max(0.0, min(1.0, confidence))
        affective_weight = max(0.0, min(1.0, affective_weight))

        # Resolve types via ontology
        subj_etype = resolve_entity_type(subject_type)
        obj_etype = resolve_entity_type(object_type) if not is_literal else None

        # Validate against ontology (strict mode rejects domain/range violations)
        validation = validate_triple(
            predicate, subj_etype, obj_etype, is_literal, strict=True,
        )
        if validation.errors:
            for e in validation.errors:
                log.debug("Ontology rejection: %s", e)
            return -1
        if validation.warnings:
            for w in validation.warnings:
                log.debug("Ontology warning: %s", w)
        # Use normalized predicate (maps unknown predicates to related_to)
        effective_predicate = validation.normalized_predicate or predicate

        # Determine knowledge type
        if knowledge_type:
            kt = knowledge_type
        else:
            kt = get_knowledge_type(effective_predicate).value

        # Resolve or create entities via registry
        subj_slug = self._registry.add(
            subject, entity_type=subj_etype, authority=authority,
        )

        obj_slug = None
        if not is_literal:
            obj_slug = self._registry.add(
                obj, entity_type=obj_etype or EntityType.THING, authority=authority,
            )

        # ── Self-loop detection: A related_to A is never meaningful ──
        if obj_slug and subj_slug == obj_slug:
            log.debug("add_triple: rejected self-loop %s --%s--> %s", subj_slug, effective_predicate, obj_slug)
            return -1

        # Dedup: same subject + predicate + object -> update (O(1) via index)
        dedup_key = (subj_slug, effective_predicate, obj_slug if obj_slug else obj)
        dedup_idx = self._dedup_index.get(dedup_key)
        triple = self._triples[dedup_idx] if dedup_idx is not None and dedup_idx < len(self._triples) else None
        if triple and triple["status"] == "active":
            if True:  # preserves original indentation block
                # Update existing — re-encountering a fact is a verification event
                auth_rank = {"inferred": 0, "system": 1, "explicit": 2}
                authority_upgraded = auth_rank.get(authority, 0) > auth_rank.get(triple.get("authority", "inferred"), 0)
                if authority_upgraded:
                    triple["authority"] = authority
                # Bayesian evidence accumulation: re-encountering a fact
                # is a positive observation. Add evidence proportional to
                # the incoming confidence rather than taking max.
                _ca = triple.get("conf_alpha")
                _cb = triple.get("conf_beta")
                if _ca is not None and _cb is not None:
                    # Existing Bayesian triple — add evidence
                    _ca += confidence * 1.0       # weighted positive observation
                    _cb += (1.0 - confidence) * 1.0
                    triple["conf_alpha"] = _ca
                    triple["conf_beta"] = _cb
                    triple["confidence"] = _ca / (_ca + _cb) if (_ca + _cb) > 0 else confidence
                elif confidence > triple.get("confidence", 0):
                    # Legacy triple without Bayesian fields — old behavior
                    triple["confidence"] = confidence
                # Track re-encounters separately from query access.
                # Dedup should NOT bump last_accessed — that defeats unaccessed decay.
                # A fact re-mentioned in passing is not the same as a fact actively queried.
                # last_accessed is only set by query_entity() and similar read paths.
                triple["last_encountered"] = now
                triple["encounter_count"] = triple.get("encounter_count", 0) + 1
                # Check for new independent source BEFORE appending
                new_source = bool(source_file and source_file not in triple.get("source_files", []))
                if new_source:
                    triple.setdefault("source_files", []).append(source_file)
                # Re-encountering a fact is corroboration evidence.
                # Set last_verified when: authority upgrade, explicit/verified,
                # OR a new independent source confirms it.
                if authority_upgraded or authority == "explicit" or epistemic_status == "verified" or new_source:
                    triple["last_verified"] = now
                # Upgrade epistemic status
                self._upgrade_epistemic(triple, epistemic_status)
                self._dirty = True
                return triple["id"]

        # Contradiction detection for single-cardinality predicates
        contradiction = self._detect_contradiction(
            subj_slug, effective_predicate, obj_slug, obj if is_literal else None,
            authority, confidence,
            new_valid_from=valid_from,
        )

        # Handle contradiction result
        _reverse_supersede = contradiction.get("_reverse_supersede") if contradiction else False
        _temporal_succession = contradiction.get("_temporal_succession") if contradiction else False

        # Cascade: if a triple was superseded, propagate to related triples.
        # Skip for temporal successions — those are clean transitions, not errors.
        if contradiction and not _reverse_supersede and not _temporal_succession:
            cascade_result = self.propagate_change(contradiction["id"])
            if cascade_result.get("cascaded", 0):
                log.info(
                    "Contradiction cascade: %d triples affected by supersession of #%d",
                    cascade_result["cascaded"], contradiction["id"],
                )

        # New triple
        triple_id = self._next_triple_id
        self._next_triple_id += 1

        # Bayesian confidence: convert point estimate to Beta posterior.
        # Authority determines the prior strength: explicit facts start
        # with more evidence (tighter posterior) than inferred ones.
        _prior_evidence = {"explicit": 20.0, "system": 10.0, "inferred": 5.0}  # arch:deterministic — prior evidence weights per authority tier
        _ev = _prior_evidence.get(authority, 5.0)
        _ca = max(0.01, confidence * _ev)
        _cb = max(0.01, (1.0 - confidence) * _ev)

        # Capture the enclosing correlation_id (if any) so user approval
        # of triple-derived content (dream-review confirm/reject) can feed
        # back into the evolution layer's per-run feedback signal.
        # Empty string when no scope is active — graceful no-op for direct
        # graph writes from non-agent code paths.
        try:
            from agntspace.activity.decisions import current_correlation_id as _cid
            _triple_corr_id = _cid() or ""
        except Exception:
            _triple_corr_id = ""

        triple: dict[str, Any] = {
            "id": triple_id,
            "subject": subj_slug,
            "predicate": effective_predicate,
            "authority": authority,
            "confidence": confidence,
            "conf_alpha": _ca,
            "conf_beta": _cb,
            "epistemic_status": epistemic_status,
            "knowledge_type": kt,
            "source_files": [source_file] if source_file else [],
            "source_date": source_date or _today_iso(),
            "decay_condition": decay_condition,
            "last_accessed": now,
            "last_verified": now if epistemic_status == "verified" or authority in ("explicit", "system") else None,
            "justified_by": [],
            "access_count": 0,
            "status": "active",
            "valid_from": valid_from or source_date or _today_iso(),
            "valid_until": valid_until,
            # ── Phase 7 bi-temporal fields (Graphiti-inspired, 2026-04-24) ──
            # World time vs system time: `valid_from`/`valid_until` describe
            # when the fact is true in REALITY. `t_created`/`t_expired`
            # describe when WE learned/invalidated the row. They're
            # orthogonal — a fact can be world-valid but system-expired
            # (we retracted our belief in it) or world-expired but
            # system-current (we still believe a stale fact). Point-in-time
            # queries over system time answer "what did I BELIEVE on date X"
            # instead of "what was actually TRUE on date X".
            "t_created": now,
            "t_expired": None,
            # ── Phase 6 CLS fields ──
            "system_layer": system_layer,
            "episode_id": episode_id,
            "situational_context": situational_context or {},
            "affective_weight": float(affective_weight) if affective_weight else 0.0,
            "prediction_outcome": prediction_outcome,
            # Hebbian co-recall counter — incremented when this triple is
            # returned alongside other triples in the same query response.
            # See Phase 6D ``bump_co_recall`` for the increment path.
            "co_recall_count": 0,
            # Evolution attribution (Phase 5+ feedback loop):
            # the correlation_id of the scope that produced this triple
            # so dream-review confirm/reject can find the dream-phase run
            # and apply feedback to its outcome.
            "correlation_id": _triple_corr_id,
        }
        if is_literal:
            triple["object_literal"] = obj
            triple["object"] = None
        else:
            triple["object"] = obj_slug
            triple["object_literal"] = None

        if contradiction and not _reverse_supersede:
            triple["supersedes"] = contradiction["id"]
        elif _reverse_supersede:
            # New triple is less credible — mark it as superseded by the existing one
            triple["status"] = "superseded"
            triple["superseded_by"] = contradiction["id"]
            triple["epistemic_status"] = EpistemicStatus.SUPERSEDED.value
            triple["valid_until"] = now
            # System time: we BORN this row already knowing it was superseded,
            # so t_expired == t_created. Downstream system-time queries treat
            # [t_created, t_expired) as the "believed" window.
            triple["t_expired"] = now

        self._triples.append(triple)
        new_idx = len(self._triples) - 1
        self._dedup_index[dedup_key] = new_idx
        self._index.on_add(new_idx, triple)
        self._dirty = True

        # Add edge to SPO graph
        if obj_slug:
            subj_name = self._registry.get_name(subj_slug)
            obj_name = self._registry.get_name(obj_slug)
            weight = AUTHORITY_WEIGHTS.get(authority, 1.0) * confidence
            if self._spo_graph.has_edge(subj_name, obj_name):
                existing = self._spo_graph[subj_name][obj_name]
                existing["weight"] = existing.get("weight", 1.0) + weight
                relations = existing.get("relations", [])
                if effective_predicate not in relations:
                    relations.append(effective_predicate)
                    existing["relations"] = relations
            else:
                self._spo_graph.add_edge(
                    subj_name, obj_name,
                    relation=effective_predicate, relations=[effective_predicate],
                    weight=weight, source="spo",
                )
            # Also add to combined graph for backwards compat
            if not self._graph.has_edge(subj_name, obj_name):
                self._graph.add_edge(
                    subj_name, obj_name,
                    relation=effective_predicate, relations=[effective_predicate],
                    weight=weight, source="spo",
                )
            else:
                existing = self._graph[subj_name][obj_name]
                existing["weight"] = existing.get("weight", 1.0) + weight
                relations = existing.get("relations", [])
                if effective_predicate not in relations:
                    relations.append(effective_predicate)
                    existing["relations"] = relations

        # Run inference rules (e.g., collaborates_with → knows)
        if not is_literal and obj_slug:
            self._run_inference(
                subject=subject, predicate=effective_predicate, obj=obj,
                subj_slug=subj_slug, obj_slug=obj_slug,
                subject_type=subject_type, object_type=object_type,
                source_file=source_file, source_date=source_date,
            )

        # Record as a verifiable mutation in the current skill scope (if any)
        try:
            from agntspace.activity.decisions import note_graph_triple
            note_graph_triple(1)
        except Exception:
            pass

        return triple_id

    # ── Phase 6 CLS layer helpers ──

    def triples_by_layer(self, layer: str, *, active_only: bool = True) -> list[dict]:
        """Return all triples in a given system layer.

        Layer is one of ``"hippocampal"``, ``"neocortical"``, or
        ``"counterfactual"``. The dream phases use this to walk
        their input set: light dream reads recent hippocampal episodes,
        deep dream reads hippocampal + writes neocortical, REM dream
        reads neocortical + writes counterfactual rejections.

        Pre-Phase-6 triples that lack a ``system_layer`` field are
        treated as ``neocortical`` (this matches the migration default
        in ``_load_graph_data``). Querying for ``"neocortical"`` returns
        them naturally; querying for ``"hippocampal"`` does not.

        ``active_only`` filters out triples whose ``status`` is anything
        other than ``"active"`` (deleted, retired, etc.) — the default
        is what almost every caller wants.

        Uses the TripleIndex for O(1) lookup when available, falls
        back to linear scan for robustness.
        """
        if active_only:
            indices = self._index.active_in_layer(layer)
            if indices or self._index.active_set:
                return [self._triples[i] for i in indices if i < len(self._triples)]
        # Fallback: linear scan (index not yet built, or active_only=False)
        out: list[dict] = []
        for t in self._triples:
            if active_only and t.get("status") != "active":
                continue
            if t.get("system_layer", "neocortical") == layer:
                out.append(t)
        return out

    def triples_by_episode(self, episode_id: str) -> list[dict]:
        """Return every triple tagged with a given episode_id.

        Used by the dream phases to walk "all facts extracted from this
        moment" — e.g. when consolidating a hippocampal episode into
        neocortical patterns, the deep-dream handler reads the full set
        of episodic triples and looks for statistical structure across
        them. Also used by the episodic recall path: "what was I doing
        the last time I saw Sarah" → look up an episode_id from a hint,
        then return its full situational context.

        Returns triples in insertion order (which is also chronological
        within an episode because triple_id is monotonic).

        Uses the TripleIndex for O(1) lookup when available.
        """
        if not episode_id:
            return []
        indices = self._index.with_episode(episode_id)
        if indices:
            return [self._triples[i] for i in indices if i < len(self._triples)]
        # Fallback: linear scan
        return [t for t in self._triples if t.get("episode_id") == episode_id]

    def bump_co_recall(self, triple_ids: list[int]) -> None:
        """Hebbian co-recall increment.

        When the agent returns multiple triples in the same query
        response, the *fact that they were retrieved together* is
        information. Real neural systems strengthen connections
        between co-active units; we strengthen the per-triple
        ``co_recall_count`` so triples that frequently appear together
        in answers gradually become more retrievable as a cluster.

        Call this from the query path AFTER the response is assembled
        with the list of triple IDs that ended up in the response.
        Calling with an empty or single-element list is a no-op
        (co-recall is meaningful only across pairs).

        Wired into ``query_entity`` as of Phase 6D — every multi-triple
        query response automatically increments the counter on the
        triples it returns. Future query paths that compose triples
        across calls (e.g. multi-hop traversal) should also call this
        with the full set of contributing triple IDs.
        """
        if not triple_ids or len(triple_ids) < 2:
            return
        triple_set = set(triple_ids)
        for t in self._triples:
            if t.get("id") in triple_set:
                t["co_recall_count"] = int(t.get("co_recall_count", 0)) + 1
        self._dirty = True

    # ── Phase 6D: Attention re-ranking + description-as-address ──

    def get_embedder(self) -> object | None:
        """Return the embedding model for neural-enhanced queries.

        Resolution order:
          1. ``self._embedder`` — explicitly set (tests, manual override)
          2. ``self._embeddings_service._get_model()`` — the real BGE-M3
             from VaultEmbeddings, lazy-loaded on first call
          3. ``None`` — no embedder available, methods fall back gracefully
        """
        if self._embedder is not None:
            return self._embedder
        svc = getattr(self, "_embeddings_service", None)
        if svc is not None and hasattr(svc, "_get_model"):
            try:
                return svc._get_model()
            except Exception:
                log.debug("get_embedder: _get_model() failed", exc_info=True)
        return None

    def _triple_text(self, triple: dict) -> str:
        """Render a triple as natural-language text for embedding.

        The output is just enough English to embed meaningfully — subject,
        predicate, object — without leaking metadata. Used by both the
        attention re-ranker and any future text-based triple search.
        """
        subj = self._registry.get_name(triple.get("subject", ""))
        pred = (triple.get("predicate") or "").replace("_", " ")
        obj_slug = triple.get("object")
        obj = self._registry.get_name(obj_slug) if obj_slug else (triple.get("object_literal") or "")
        return f"{subj} {pred} {obj}".strip()

    def rerank_by_attention(
        self,
        triples: list[dict],
        context_text: str,
        embedder: object | None,
        *,
        top_k: int | None = None,
    ) -> list[dict]:
        """Re-rank triples by embedding similarity to a context string.

        Modern attention mechanisms ask "given this query, which inputs
        matter?". We do the same here at query time: embed the conversation
        context once, embed each candidate triple's textualization, score by
        cosine similarity, and return the list re-ordered by score (with
        the original ranking preserved as a stable secondary sort).

        Each result triple gets two extra fields injected for caller
        introspection:
          - ``attention_score`` — float in [-1, 1] (typically 0..1 for text)
          - ``attention_rank`` — 1-based position after re-ranking

        ``embedder`` must expose ``embed(texts: list[str]) -> list[vector]``.
        Pass the Ollama-backed ``OllamaEmbedModel`` (or any object with
        the same shape — fastembed-compatible stubs work for tests). If
        ``embedder`` is None, this is a no-op: the original list is
        returned unchanged. Same for empty inputs.

        ``top_k``, if set, truncates the re-ranked output. Useful for
        keeping the agent's context window small.

        This method is pure (no graph mutation, no LLM call) and synchronous
        — it can be called from any code path including the chat hot loop.
        """
        if not triples or embedder is None:
            return triples[:top_k] if top_k else triples
        if not context_text or not isinstance(context_text, str):
            return triples[:top_k] if top_k else triples

        import math

        try:
            texts = [self._triple_text(t) for t in triples]
            # Single batch embed: context + every triple text in one call
            all_vecs = list(embedder.embed([context_text, *texts]))
        except Exception:
            log.debug("rerank_by_attention: embedder failed, falling back to original order", exc_info=True)
            return triples[:top_k] if top_k else triples

        if len(all_vecs) != len(triples) + 1:
            log.debug("rerank_by_attention: embedder returned wrong shape, skipping")
            return triples[:top_k] if top_k else triples

        # Pure-Python cosine — no numpy dep. Caller contract: every vector
        # is an iterable of floats (list, tuple, or numpy ndarray all work
        # via float() coercion).
        ctx_vec = [float(x) for x in all_vecs[0]]
        ctx_sq = sum(v * v for v in ctx_vec)
        if ctx_sq <= 0.0:
            return triples[:top_k] if top_k else triples
        ctx_norm = math.sqrt(ctx_sq)

        scored: list[tuple[float, int, dict]] = []
        for idx, (t, raw_vec) in enumerate(zip(triples, all_vecs[1:], strict=False)):
            try:
                vec = [float(x) for x in raw_vec]
            except (TypeError, ValueError):
                vec = []
            if not vec or len(vec) != len(ctx_vec):
                score = 0.0
            else:
                vec_sq = sum(v * v for v in vec)
                if vec_sq <= 0.0:
                    score = 0.0
                else:
                    dot = sum(a * b for a, b in zip(ctx_vec, vec, strict=False))
                    score = dot / (ctx_norm * math.sqrt(vec_sq))
            # Phase 6D Hebbian boost: triples that are frequently
            # co-recalled with other triples get a small score bump.
            # The boost is logarithmic so it saturates — the first few
            # co-recalls matter most, diminishing returns after ~20.
            # Max boost is ~0.13 (log2(21)/100) which is enough to
            # break ties between equally-relevant triples but never
            # enough to override a genuinely better embedding match.
            co_recall = int(t.get("co_recall_count", 0) if isinstance(t, dict) else 0)
            if co_recall > 0:
                score += math.log2(1 + co_recall) / 100.0
            scored.append((score, idx, t))

        # Sort by score descending; stable on original index for ties
        scored.sort(key=lambda x: (-x[0], x[1]))

        out: list[dict] = []
        for rank, (score, _idx, original) in enumerate(scored, start=1):
            # Shallow-copy the triple so we don't mutate the input
            new_t = dict(original)
            new_t["attention_score"] = round(score, 4)
            new_t["attention_rank"] = rank
            out.append(new_t)

        if top_k:
            out = out[:top_k]
        return out

    # ── Phase 6G/H: Counterfactual + dream review surfaces ──

    def list_rejected_hypotheses(
        self,
        *,
        limit: int = 50,
        since_iso: str | None = None,
    ) -> list[dict]:
        """Return rejected hypotheses (the counterfactual layer) for review.

        Each entry is shaped to be human-readable: subject + predicate +
        object as display strings, the rejection reason from
        situational_context, the defeating-evidence episode IDs, and
        provenance metadata. Sorted newest-first by ``last_accessed``
        so the user sees recent rejections at the top.

        Returns at most ``limit`` entries. ``since_iso`` filters to
        rejections that happened on or after the given ISO timestamp
        (matched against the triple's ``last_accessed``, which gets
        bumped on the rejection write).

        This is the audit surface for "things the agent considered and
        explicitly rejected, with the evidence that defeated them" --
        the gap that pure vector-memory systems cannot answer at all.
        """
        out: list[dict] = []
        for t in self._triples:
            if t.get("status") != "active":
                continue
            if t.get("system_layer") != "counterfactual":
                continue
            if t.get("epistemic_status") != "rejected_hypothesis":
                continue
            if since_iso and (t.get("last_accessed") or "") < since_iso:
                continue
            ctx = t.get("situational_context") or {}
            subj_name = self._registry.get_name(t.get("subject", ""))
            obj_slug = t.get("object")
            obj_display = (
                self._registry.get_name(obj_slug) if obj_slug
                else (t.get("object_literal") or "")
            )
            out.append({
                "id": t.get("id"),
                "subject": subj_name,
                "predicate": (t.get("predicate") or "").replace("_", " "),
                "object": obj_display,
                "rejection_reason": ctx.get("rejection_reason"),
                "defeating_evidence": ctx.get("defeating_evidence_episodes", []),
                "rejected_at": t.get("last_accessed"),
                "original_authority": t.get("authority"),
                "original_source_files": t.get("source_files", []),
            })
        # Sort by triple id descending -- ids are monotonic and survive
        # the case where multiple rejections happen in the same wall-clock
        # second (which would otherwise tie on ``rejected_at``).
        out.sort(key=lambda r: r.get("id") or 0, reverse=True)
        return out[:limit]

    def restore_rejected_hypothesis(self, triple_id: int) -> bool:
        """Move a rejected hypothesis back to the neocortical layer.

        User action: "actually I think the agent was wrong to reject this".
        Restores the triple to ``system_layer=neocortical``,
        ``epistemic_status=contested`` (NOT verified — just back in play
        for further evidence), and clears the rejection metadata from
        situational_context.

        Returns True if the restoration happened, False if the triple
        wasn't a rejected hypothesis.
        """
        for t in self._triples:
            if t.get("id") != triple_id:
                continue
            if (
                t.get("system_layer") != "counterfactual"
                or t.get("epistemic_status") != "rejected_hypothesis"
            ):
                return False
            t["system_layer"] = "neocortical"
            t["epistemic_status"] = "contested"
            ctx = t.get("situational_context") or {}
            if isinstance(ctx, dict):
                ctx.pop("rejection_reason", None)
                ctx.pop("defeating_evidence_episodes", None)
                t["situational_context"] = ctx
            t["last_accessed"] = _now_iso()
            self._dirty = True
            return True
        return False

    def dream_review_queue(
        self,
        *,
        min_effective_weight: float = 0.5,
        limit: int = 50,
        layer: str = "neocortical",
    ) -> list[dict]:
        """Return high-importance dream-extracted triples for user review.

        Phase 6H -- the surface for "the agent thinks these things; do
        you confirm them?". Filters to triples that:

          1. Live in the requested layer (default neocortical -- the
             dream phases' main output target).
          2. Have ``authority=system`` (auto-extracted) and not yet
             verified (``epistemic_status`` in believed/uncertain).
          3. Have ``effective_weight >= min_effective_weight`` so the
             user reviews the *most consequential* dream beliefs first
             rather than getting drowned in noise.
          4. Carry a ``notes`` field (which the dream handlers populate
             with rationale strings) -- this is the "I have something
             to defend with" filter.

        Sorted by effective_weight descending. Each entry is shaped
        for human review: subject/predicate/object as display strings,
        the rationale notes, source provenance, and the metrics that
        explain why the agent surfaced this for review.

        This closes the "no human review of auto-written memories"
        gap that the OpenClaw analysis flagged. The user reviews the
        top-N most-load-bearing dream beliefs and confirms or rejects
        them; everything else is either decayed by temporal weight or
        cascades when a dependency is corrected.
        """
        candidates = []
        for t in self._triples:
            if t.get("status") != "active":
                continue
            if t.get("system_layer") != layer:
                continue
            if t.get("authority") != "system":
                continue
            if t.get("epistemic_status") not in ("believed", "uncertain"):
                continue
            ew = t.get("effective_weight")
            if ew is None or ew < min_effective_weight:
                continue
            if not t.get("notes"):
                continue
            candidates.append(t)
        candidates.sort(key=lambda x: x.get("effective_weight") or 0.0, reverse=True)
        candidates = candidates[:limit]

        out: list[dict] = []
        for t in candidates:
            subj_name = self._registry.get_name(t.get("subject", ""))
            obj_slug = t.get("object")
            obj_display = (
                self._registry.get_name(obj_slug) if obj_slug
                else (t.get("object_literal") or "")
            )
            out.append({
                "id": t.get("id"),
                "subject": subj_name,
                "predicate": (t.get("predicate") or "").replace("_", " "),
                "object": obj_display,
                "knowledge_type": t.get("knowledge_type"),
                "epistemic_status": t.get("epistemic_status"),
                "confidence": t.get("confidence"),
                "effective_weight": t.get("effective_weight"),
                "rationale_notes": list(t.get("notes", [])),
                "source_files": list(t.get("source_files", [])),
                "justified_by": list(t.get("justified_by", [])),
                "first_seen": t.get("first_seen", t.get("source_date", "")),
            })
        return out

    def confirm_dream_belief(self, triple_id: int) -> bool:
        """User action: promote a dream-extracted belief to verified.

        Bumps the triple's authority from ``system`` to ``explicit``,
        sets ``epistemic_status=verified``, and updates ``last_verified``
        to now. The user has reviewed the agent's auto-extracted belief
        and confirmed it. Returns True on success, False if the triple
        doesn't exist or isn't a dream-extractable target (already
        verified, wrong authority, etc.).
        """
        for t in self._triples:
            if t.get("id") != triple_id:
                continue
            if t.get("authority") not in ("system", "inferred"):
                return False
            if t.get("epistemic_status") in ("verified", "retracted", "rejected_hypothesis"):
                return False
            t["authority"] = "explicit"
            t["epistemic_status"] = "verified"
            t["last_verified"] = _now_iso()
            # Bayesian: user confirmation is strong positive evidence.
            # Add evidence equivalent to 5 positive observations.
            _ca = t.get("conf_alpha")
            _cb = t.get("conf_beta")
            if _ca is not None and _cb is not None:
                t["conf_alpha"] = _ca + 5.0
                t["confidence"] = t["conf_alpha"] / (t["conf_alpha"] + _cb)
            self._dirty = True
            return True
        return False

    def reject_dream_belief(self, triple_id: int, *, reason: str = "") -> bool:
        """User action: reject a dream-extracted belief.

        Marks the triple as ``epistemic_status=retracted`` (final --
        the user actively disagrees) and records the rejection reason
        in situational_context. The cascade propagation that already
        runs on every status change will downgrade triples justified
        by this one. Returns True on success.

        Distinct from ``restore_rejected_hypothesis`` (which moves a
        REM-rejected hypothesis BACK to active). This action moves an
        active dream belief OUT, in the opposite direction.
        """
        for t in self._triples:
            if t.get("id") != triple_id:
                continue
            if t.get("epistemic_status") == "retracted":
                return False
            t["epistemic_status"] = "retracted"
            ctx = t.get("situational_context") or {}
            if not isinstance(ctx, dict):
                ctx = {}
            if reason:
                ctx["user_rejection_reason"] = reason
            ctx["user_rejected_at"] = _now_iso()
            t["situational_context"] = ctx
            # Phase 7 bi-temporal: stamp system-time expiry on user retraction.
            t["t_expired"] = _now_iso()
            self._dirty = True
            return True
        return False

    def entity_affective_map(self) -> dict[str, dict]:
        """Return per-entity aggregate affective weight for heat map visualization.

        Walks all active triples and computes, for each entity that appears
        as subject or object, the maximum affective_weight across its
        triples. Max (not mean) because one deeply emotional fact about
        a person matters more than ten neutral logistics — the heat map
        should highlight "this entity is emotionally significant" not
        "this entity has many triples."

        Returns::

            {
                "entity-slug": {
                    "name": "Display Name",
                    "entity_type": "person",
                    "max_affective": 0.8,
                    "triple_count": 5,
                },
                ...
            }

        Only entities with at least one triple carrying affective_weight > 0
        are included (keeps the response small for large graphs).
        """
        entity_data: dict[str, dict] = {}
        for t in self._triples:
            if t.get("status") != "active":
                continue
            aw = float(t.get("affective_weight", 0.0) or 0.0)
            for slug_key in ("subject", "object"):
                slug = t.get(slug_key)
                if not slug:
                    continue
                if slug not in entity_data:
                    entity_data[slug] = {
                        "name": self._registry.get_name(slug),
                        "entity_type": self._registry.entities.get(slug, {}).get("entity_type", "thing"),
                        "max_affective": 0.0,
                        "triple_count": 0,
                    }
                entry = entity_data[slug]
                entry["triple_count"] += 1
                if aw > entry["max_affective"]:
                    entry["max_affective"] = aw
        # Filter to only entities with affect > 0
        return {
            slug: data for slug, data in entity_data.items()
            if data["max_affective"] > 0.0
        }

    def resolve_by_description(
        self,
        description: str,
        embedder: object | None,
        *,
        top_k: int = 5,
        min_score: float = 0.3,
    ) -> list[dict]:
        """Find candidate entity slugs that match a free-text description.

        Real cognition resolves names by content, not by symbol address.
        Asking "the engineer who helped me with the migration" should
        ideally land on Sarah Chen even if "engineer" and "migration"
        aren't in her canonical name. We do this by embedding the
        description and the textual handle of every entity in the
        registry, then ranking by cosine similarity.

        Returns a list of dicts shaped::

            {"slug": "sarah-chen", "name": "Sarah Chen",
             "entity_type": "person", "score": 0.71, "rank": 1}

        Filtered by ``min_score`` (cosine similarity floor — anything
        below is too weak to be useful) and capped at ``top_k``. Returns
        an empty list if the embedder is missing, the description is
        empty, or no entities clear the floor.

        This is a complement to ``EntityRegistry.resolve()`` (which does
        exact / alias / fuzzy lookup on names). Call this when ``resolve()``
        returns None, OR proactively at chat time to surface "you might
        also mean" candidates for ambiguous references.

        The method does NOT mutate the graph. It is safe to call from any
        code path. Pure read-only resolution.
        """
        if not description or not isinstance(description, str):
            return []
        if embedder is None:
            return []

        import math

        # Build the candidate set: every entity in the registry, with a
        # text handle that includes the canonical name + entity_type so the
        # embedder gets a meaningful semantic context.
        entities = list(self._registry.entities.items())
        if not entities:
            return []

        candidate_texts = [
            f"{ent.get('canonical_name', slug)} ({ent.get('entity_type', 'thing')})"
            for slug, ent in entities
        ]

        try:
            all_vecs = list(embedder.embed([description, *candidate_texts]))
        except Exception:
            log.debug("resolve_by_description: embedder failed", exc_info=True)
            return []

        if len(all_vecs) != len(entities) + 1:
            return []

        # Pure-Python cosine; no numpy required.
        try:
            query_vec = [float(x) for x in all_vecs[0]]
        except (TypeError, ValueError):
            return []
        query_sq = sum(v * v for v in query_vec)
        if query_sq <= 0.0:
            return []
        query_norm = math.sqrt(query_sq)

        scored: list[tuple[float, str, dict]] = []
        for (slug, ent), raw_vec in zip(entities, all_vecs[1:], strict=False):
            try:
                vec = [float(x) for x in raw_vec]
            except (TypeError, ValueError):
                continue
            if len(vec) != len(query_vec):
                continue
            vec_sq = sum(v * v for v in vec)
            if vec_sq <= 0.0:
                continue
            dot = sum(a * b for a, b in zip(query_vec, vec, strict=False))
            score = dot / (query_norm * math.sqrt(vec_sq))
            if score < min_score:
                continue
            scored.append((score, slug, ent))

        scored.sort(key=lambda x: -x[0])
        out = []
        for rank, (score, slug, ent) in enumerate(scored[:top_k], start=1):
            out.append({
                "slug": slug,
                "name": ent.get("canonical_name", slug),
                "entity_type": ent.get("entity_type", "thing"),
                "score": round(score, 4),
                "rank": rank,
            })
        return out

    def _run_inference(
        self,
        subject: str,
        predicate: str,
        obj: str,
        subj_slug: str,
        obj_slug: str,
        subject_type: str,
        object_type: str,
        source_file: str,
        source_date: str,
    ) -> None:
        """Execute inference rules for a newly added triple.

        Inferred triples get authority=inferred, epistemic_status=believed,
        and justified_by pointing to the source triple that triggered the inference.
        This creates a proper justification chain for epistemic cascading.
        """
        # Find the source triple ID (the one we just added that triggered inference)
        source_triple_id = None
        for t in reversed(self._triples):
            if (t["subject"] == subj_slug and t["predicate"] == predicate
                    and t["status"] == "active"):
                source_triple_id = t["id"]
                break

        rules = get_inferences(predicate)
        for rule in rules:
            if rule.swap:
                inf_subject, inf_obj = obj, subject
                inf_subj_type, inf_obj_type = object_type, subject_type
            else:
                inf_subject, inf_obj = subject, obj
                inf_subj_type, inf_obj_type = subject_type, object_type

            # add_triple handles dedup — if this inference already exists, it's a no-op
            inferred_id = self.add_triple(
                subject=inf_subject,
                predicate=rule.then_predicate,
                obj=inf_obj,
                authority="inferred",
                source_file=source_file,
                source_date=source_date,
                confidence=0.9,
                subject_type=inf_subj_type,
                object_type=inf_obj_type,
                epistemic_status="believed",
            )

            # Record justification dependency: inferred triple depends on source triple
            if source_triple_id:
                for t in self._triples:
                    if t["id"] == inferred_id:
                        justified_by = t.get("justified_by", [])
                        if source_triple_id not in justified_by:
                            justified_by.append(source_triple_id)
                            t["justified_by"] = justified_by
                        break

    def _detect_contradiction(
        self,
        subj_slug: str,
        predicate: str,
        obj_slug: str | None,
        obj_literal: str | None,
        new_authority: str,
        new_confidence: float,
        new_valid_from: str | None = None,
    ) -> dict | None:
        """Detect if a new triple contradicts an existing single-cardinality fact.

        For single-cardinality predicates (e.g., works_at), if a different object
        exists for the same subject, the old triple is superseded.

        **Temporal awareness**: when both triples have ``valid_from`` dates and
        the new date is later, this is **temporal succession** — the old fact
        was true in its time, the new fact is true now. The old triple gets
        ``valid_until`` set to the new triple's date but keeps its epistemic
        status as ``believed`` (not ``superseded``). This preserves history:
        "Alice worked at Acme (2020-2024)" and "Alice works at Google (2024-)"
        are both true, just at different times.

        Returns the superseded/succeeded triple dict, or None.
        """
        if not is_single_cardinality(predicate):
            return None

        for t in self._triples:
            if t["status"] != "active":
                continue
            if t["subject"] != subj_slug or t["predicate"] != predicate:
                continue

            # Same subject + predicate, check if different object
            existing_obj = t.get("object") or t.get("object_literal")
            new_obj = obj_slug or obj_literal

            if existing_obj == new_obj:
                continue  # same fact, not a contradiction

            # ── Temporal succession check ──
            # If both triples have dates and the new one is later, this is
            # a temporal transition, not a contradiction. Close the old
            # triple's validity window without marking it epistemically wrong.
            existing_from = t.get("valid_from")
            if existing_from and new_valid_from and new_valid_from > existing_from:
                subj_name = self._registry.get_name(subj_slug)
                existing_obj_name = (
                    self._registry.get_name(t.get("object"))
                    if t.get("object") else t.get("object_literal", "?")
                )
                new_obj_name = (
                    self._registry.get_name(obj_slug)
                    if obj_slug else (obj_literal or "?")
                )
                log.info(
                    "Temporal succession: %s %s %s (%s) → %s (%s). "
                    "Closing old triple #%d validity window.",
                    subj_name, predicate, existing_obj_name, existing_from,
                    new_obj_name, new_valid_from, t["id"],
                )
                t["valid_until"] = new_valid_from
                # Keep epistemic_status unchanged — the old fact was true
                # in its time. Mark as _temporal_succession so the caller
                # knows this was a clean transition, not a conflict.
                self._dirty = True
                return {"_temporal_succession": True, "id": t["id"]}

            # ── Standard contradiction ──
            # Compare credibility. Higher authority × confidence wins.
            # If equal, newer wins.
            _auth_rank = {"inferred": 0, "system": 1, "explicit": 2}
            existing_score = _auth_rank.get(t.get("authority", "inferred"), 0) * t.get("confidence", 0.8)
            new_score = _auth_rank.get(new_authority, 0) * new_confidence

            subj_name = self._registry.get_name(subj_slug)
            existing_obj_name = (
                self._registry.get_name(t.get("object"))
                if t.get("object") else t.get("object_literal", "?")
            )
            new_obj_name = (
                self._registry.get_name(obj_slug)
                if obj_slug else (obj_literal or "?")
            )

            if new_score < existing_score:
                log.info(
                    "Contradiction detected: %s %s %s (existing, score=%.2f) vs %s (new, score=%.2f). "
                    "Keeping existing — new triple will be marked contested.",
                    subj_name, predicate, existing_obj_name, existing_score, new_obj_name, new_score,
                )
                return {"_reverse_supersede": True, "id": t["id"]}

            log.info(
                "Contradiction detected: %s %s %s (existing, score=%.2f) vs %s (new, score=%.2f). "
                "Superseding old triple #%d.",
                subj_name, predicate, existing_obj_name, existing_score, new_obj_name, new_score, t["id"],
            )

            # Supersede the old triple
            t["status"] = "superseded"
            t["superseded_by"] = self._next_triple_id
            t["valid_until"] = _now_iso()
            t["epistemic_status"] = EpistemicStatus.SUPERSEDED.value
            t["superseded_at"] = _now_iso()
            # Phase 7 bi-temporal: stamp system-time expiry. Same value as
            # superseded_at by design — superseded_at is the historical
            # name for "when we invalidated our belief", t_expired is the
            # bi-temporal alias that composes with t_created for clean
            # system-time interval queries.
            t["t_expired"] = _now_iso()

            return t

        # ── Multi-hop: check inverse predicate contradictions ──
        # If we're adding "A works_at X" and the inverse "employs" is
        # single-cardinality, check if "X employs someone_else" already
        # exists — that's a transitive contradiction.
        inverse_pred = get_inverse(predicate)
        if inverse_pred and is_single_cardinality(inverse_pred) and obj_slug:
            for t in self._triples:
                if t["status"] != "active":
                    continue
                if t["subject"] != obj_slug or t["predicate"] != inverse_pred:
                    continue
                existing_obj = t.get("object")
                if existing_obj == subj_slug:
                    continue  # same fact via inverse, not a contradiction

                _auth_rank = {"inferred": 0, "system": 1, "explicit": 2}
                existing_score = _auth_rank.get(t.get("authority", "inferred"), 0) * t.get("confidence", 0.8)
                new_score = _auth_rank.get(new_authority, 0) * new_confidence

                if new_score >= existing_score:
                    log.info(
                        "Inverse contradiction: %s %s %s conflicts with %s %s %s. "
                        "Superseding old triple #%d.",
                        subj_slug, predicate, obj_slug,
                        obj_slug, inverse_pred, existing_obj, t["id"],
                    )
                    t["status"] = "superseded"
                    t["superseded_by"] = self._next_triple_id
                    t["valid_until"] = _now_iso()
                    t["epistemic_status"] = EpistemicStatus.SUPERSEDED.value
                    t["superseded_at"] = _now_iso()
                    t["t_expired"] = _now_iso()  # Phase 7 bi-temporal
                    return t

        return None

    @staticmethod
    def _upgrade_epistemic(triple: dict, new_status: str) -> None:
        """Upgrade epistemic status if the new one is stronger."""
        rank = {
            "retracted": 0, "superseded": 1, "contested": 2,
            "uncertain": 3, "believed": 4, "verified": 5,
        }
        current = triple.get("epistemic_status", "believed")
        if rank.get(new_status, 0) > rank.get(current, 0):
            triple["epistemic_status"] = new_status

    # ── Querying ──

    def query_entity(self, entity: str, depth: int = 2) -> dict | None:
        """Query everything known about an entity.

        Returns structured result with entity data, triples grouped by predicate,
        connected entities, and epistemic metadata.

        Fix #4: Results are filtered by effective_weight (excludes near-zero facts)
        and sorted by weight within each predicate group so the agent sees the
        most reliable facts first.
        """
        slug = self._registry.resolve(entity)
        if not slug:
            return None

        ent = self._registry.entities[slug]
        self._registry.bump_access(slug)

        # Collect triples where this entity is subject or object.
        # Uses the TripleIndex for O(1) candidate selection instead of
        # a full O(n) scan. The index provides the set of active triples
        # matching the slug; we still filter by effective_weight and
        # status inline because those are post-reweight derived values.
        triples_as_subject: dict[str, list[dict]] = {}
        triples_as_object: dict[str, list[dict]] = {}
        # Phase 6D: collect every triple id we touch so we can call
        # bump_co_recall once at the end.
        touched_triple_ids: list[int] = []
        min_qw = _load_memory_policy()["min_query_weight"]

        # Walk indexed subject candidates
        for idx in self._index.active_with_subject(slug):
            t = self._triples[idx]
            if t["status"] not in ("active", "contested"):
                continue
            ew = t.get("effective_weight")
            if ew is not None and ew < min_qw:
                continue
            pred = t["predicate"]
            obj_display = self._registry.get_name(t["object"]) if t["object"] else t.get("object_literal", "?")
            triples_as_subject.setdefault(pred, []).append({
                "value": obj_display,
                "confidence": t["confidence"],
                "authority": t["authority"],
                "epistemic_status": t.get("epistemic_status", "believed"),
                "knowledge_type": t.get("knowledge_type", "declarative"),
                "source_date": t.get("source_date", ""),
                "effective_weight": ew,
            })
            t["last_accessed"] = _now_iso()
            t["access_count"] = t.get("access_count", 0) + 1
            if t.get("id") is not None:
                touched_triple_ids.append(t["id"])

        # Walk indexed object candidates
        for idx in self._index.active_with_object(slug):
            t = self._triples[idx]
            if t["status"] not in ("active", "contested"):
                continue
            ew = t.get("effective_weight")
            if ew is not None and ew < min_qw:
                continue
            pred = t["predicate"]
            subj_display = self._registry.get_name(t["subject"])
            triples_as_object.setdefault(pred, []).append({
                "value": subj_display,
                "confidence": t["confidence"],
                "authority": t["authority"],
                "epistemic_status": t.get("epistemic_status", "believed"),
                "knowledge_type": t.get("knowledge_type", "declarative"),
                "source_date": t.get("source_date", ""),
                "effective_weight": ew,
            })
            t["last_accessed"] = _now_iso()
            t["access_count"] = t.get("access_count", 0) + 1
            if t.get("id") is not None:
                touched_triple_ids.append(t["id"])

        # Sort each predicate group by effective_weight descending
        for pred_triples in triples_as_subject.values():
            pred_triples.sort(key=lambda x: x.get("effective_weight") or 0, reverse=True)
        for pred_triples in triples_as_object.values():
            pred_triples.sort(key=lambda x: x.get("effective_weight") or 0, reverse=True)

        # Phase 6D: Hebbian co-recall — strengthen the association between
        # all triples retrieved together in this query response. This is
        # passive observation accumulation; effects show up in future
        # re-ranking once enough data accumulates.
        self.bump_co_recall(touched_triple_ids)

        # Get graph connections (combined graph for broader reach)
        connected = self.get_connected(ent["canonical_name"], depth=depth)

        return {
            "entity": {
                "slug": slug,
                "name": ent["canonical_name"],
                "type": ent.get("entity_type", "thing"),
                "authority": ent.get("authority", "inferred"),
                "first_seen": ent.get("first_seen", ""),
                "access_count": ent.get("access_count", 0),
            },
            "facts": triples_as_subject,
            "referenced_by": triples_as_object,
            "connected": connected,
        }

    def query_path(self, entity_a: str, entity_b: str) -> list[dict] | None:
        """Find shortest path between two entities using SPO graph only.

        Unlike the old implementation, this uses the directed SPO graph,
        so paths are semantically meaningful (not just co-mentions).
        Falls back to combined graph if no SPO path exists.
        """
        slug_a = self._registry.resolve(entity_a)
        slug_b = self._registry.resolve(entity_b)
        if not slug_a or not slug_b:
            return None

        name_a = self._registry.get_name(slug_a)
        name_b = self._registry.get_name(slug_b)

        # Try SPO graph first (semantically meaningful paths)
        path = None
        try:
            # Treat as undirected for path finding (relationships work both ways)
            path = nx.shortest_path(self._spo_graph.to_undirected(), name_a, name_b)
        except (nx.NetworkXError, nx.NodeNotFound):
            pass

        # Fallback to combined graph
        if not path:
            try:
                path = nx.shortest_path(self._graph, name_a, name_b)
            except (nx.NetworkXError, nx.NodeNotFound):
                return None

        # Build path with supporting triples
        result = []
        for i in range(len(path) - 1):
            from_node = path[i]
            to_node = path[i + 1]

            from_slug = self._registry.resolve(from_node)
            to_slug = self._registry.resolve(to_node)
            supporting = []
            if from_slug and to_slug:
                for t in self._triples:
                    if t["status"] != "active":
                        continue
                    if ((t["subject"] == from_slug and t.get("object") == to_slug)
                            or (t["subject"] == to_slug and t.get("object") == from_slug)):
                        supporting.append({
                            "predicate": t["predicate"],
                            "confidence": t["confidence"],
                            "authority": t["authority"],
                            "epistemic_status": t.get("epistemic_status", "believed"),
                        })

            # Determine edge type
            edge_data = {}
            if self._spo_graph.has_edge(from_node, to_node):
                edge_data = dict(self._spo_graph[from_node][to_node])
            elif self._spo_graph.has_edge(to_node, from_node):
                edge_data = dict(self._spo_graph[to_node][from_node])
            elif self._graph.has_edge(from_node, to_node):
                edge_data = dict(self._graph[from_node][to_node])

            relations = edge_data.get("relations", [edge_data.get("relation", "connected")])

            result.append({
                "from": from_node,
                "to": to_node,
                "relations": relations,
                "triples": supporting,
                "semantic": bool(supporting),  # True if backed by SPO triples
            })

        return result

    def get_contradictions(self) -> list[dict]:
        """Find all contradictions in the graph.

        Returns pairs of triples that conflict (same subject + single-cardinality
        predicate, different objects, both active/contested).
        """
        contradictions = []
        seen = set()

        for t in self._triples:
            if t["status"] not in ("active", "contested"):
                continue
            if not is_single_cardinality(t["predicate"]):
                continue

            key = (t["subject"], t["predicate"])
            if key in seen:
                continue

            # Find all active triples with same subject+predicate
            matching = [
                other for other in self._triples
                if other["status"] in ("active", "contested")
                and other["subject"] == t["subject"]
                and other["predicate"] == t["predicate"]
                and other["id"] != t["id"]
            ]

            if matching:
                seen.add(key)
                subj_name = self._registry.get_name(t["subject"])
                for other in matching:
                    contradictions.append({
                        "subject": subj_name,
                        "predicate": t["predicate"],
                        "triple_a": {
                            "id": t["id"],
                            "object": self._registry.get_name(t.get("object")) or t.get("object_literal"),
                            "authority": t["authority"],
                            "confidence": t["confidence"],
                            "source_date": t.get("source_date"),
                        },
                        "triple_b": {
                            "id": other["id"],
                            "object": self._registry.get_name(other.get("object")) or other.get("object_literal"),
                            "authority": other["authority"],
                            "confidence": other["confidence"],
                            "source_date": other.get("source_date"),
                        },
                    })

        return contradictions

    # ── LLM triple extraction ──

    # Tool-calling schema for structured triple extraction.
    # Using function calling instead of free-form JSON eliminates parsing
    # failures from code fences, preamble text, and malformed JSON.
    _TRIPLE_EXTRACT_TOOL = {
        "name": "record_triples",
        "description": "Record structured knowledge triples extracted from text.",
        "input_schema": {
            "type": "object",
            "properties": {
                "triples": {
                    "type": "array",
                    "maxItems": 30,
                    "items": {
                        "type": "object",
                        "properties": {
                            "subject": {"type": "string", "description": "Full entity name (proper noun, not a pronoun or generic word)"},
                            "subject_type": {"type": "string", "enum": ["person", "organization", "concept", "event", "place", "project", "task", "goal", "document", "skill", "ai_agent", "decision", "thing"]},
                            "predicate": {"type": "string", "description": "Relationship type from the ontology"},
                            "object": {"type": "string", "description": "Full entity name or attribute value"},
                            "object_type": {"type": "string", "enum": ["person", "organization", "concept", "event", "place", "project", "task", "goal", "document", "skill", "ai_agent", "decision", "thing", "literal"]},
                            "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
                            "knowledge_type": {"type": "string", "enum": ["declarative", "episodic", "procedural", "normative", "intentional"]},
                        },
                        "required": ["subject", "subject_type", "predicate", "object", "object_type", "confidence", "knowledge_type"],
                    },
                },
            },
            "required": ["triples"],
        },
    }

    async def _extract_via_tool_call(
        self, text: str, vocab: str, source_file: str,
    ) -> list[dict] | None:
        """Extract triples using structured tool calling (preferred path).

        Returns a list of triple dicts on success, or None if tool calling
        is unavailable or fails (caller should fall back to freeform).
        """
        if not self._llm or not hasattr(self._llm, "complete_with_tools"):
            return None

        from agntspace.llm.base import Message

        system = (
            "Extract structured knowledge triples from the user's text.\n"
            f"{vocab}\n"
            "Rules:\n"
            "- Only extract facts explicitly stated in the text\n"
            "- Use full proper names, never pronouns or generic words\n"
            "- Prefer specific predicates from the ontology over 'related_to'\n"
            "- Max 30 triples\n"
            "Call the record_triples tool with your extractions."
        )

        try:
            response = await self._llm.complete_with_tools(
                messages=[Message(role="user", content=text)],
                tools=[self._TRIPLE_EXTRACT_TOOL],
                system=system,
                max_tokens=2048,
                temperature=0.1,
            )
        except Exception as e:
            log.debug("Tool-call extraction failed, will fall back: %s", e)
            return None

        # Parse the tool call result
        if not response.tool_calls:
            return None

        for tc in response.tool_calls:
            if tc.name == "record_triples":
                triples = tc.input.get("triples", [])
                if isinstance(triples, list):
                    return triples
        return None

    async def _extract_via_freeform(
        self, text: str, vocab: str, source_file: str,
    ) -> list[dict] | None:
        """Extract triples via free-form JSON (fallback path).

        Used when tool calling is unavailable (agent path) or fails.
        """
        task_instruction = (
            "Extract structured knowledge triples from the text below as a JSON array.\n"
            "Each triple: {subject, subject_type, predicate, object, object_type, confidence, knowledge_type}.\n"
            f"{vocab}\n"
            "Rules: only facts present in text, prefer specific predicates, full entity names, max 30 triples.\n"
            "Return ONLY the JSON array, no other text."
        )

        try:
            if self._agent:
                raw_response = await self._agent.process_task(
                    task=task_instruction, context=text, model_tier="fast",
                )
            elif self._llm:
                from agntspace.llm.base import Message
                response = await self._llm.complete(
                    messages=[Message(role="user", content=text)],
                    system=_build_triple_extract_prompt(),
                    max_tokens=2048,
                    temperature=0.2,
                )
                raw_response = response.content
            else:
                return None
        except Exception as e:
            log.warning("Triple extraction failed: %s", e)
            return None

        raw = raw_response.strip()
        if raw.startswith("```"):
            lines = raw.split("\n")
            raw = "\n".join(lines[1:])
            if raw.endswith("```"):
                raw = raw[:-3]
            raw = raw.strip()

        try:
            triples_data = json.loads(raw)
        except json.JSONDecodeError:
            log.warning("Failed to parse triple extraction JSON")
            self._extraction_metrics["failed_extractions"] += 1
            return None

        if not isinstance(triples_data, list):
            self._extraction_metrics["failed_extractions"] += 1
            return None

        return triples_data

    async def extract_triples_llm(
        self,
        text: str,
        source_file: str = "",
        source_date: str = "",
        authority: str = "system",
    ) -> list[dict]:
        """Extract structured triples from text via the main agent.

        Markdown structure (headers, bullets, code fences, frontmatter, HTML)
        is stripped before the text reaches the LLM, so structural scaffolding
        (section labels like "## User Journal") cannot be mistaken for
        entities. This invariant is language-agnostic and enforced centrally.
        """
        if not self._agent and not self._llm:
            return []

        from agntspace.text import strip_markdown_structure

        text = strip_markdown_structure(text)
        if not text.strip():
            return []

        vocab = build_extraction_prompt()

        triples_data = await self._extract_via_tool_call(text, vocab, source_file)
        if triples_data is None:
            # Fallback: free-form JSON (agent path or tool-call failure)
            triples_data = await self._extract_via_freeform(text, vocab, source_file)
        if triples_data is None:
            return []

        self._extraction_metrics["total_extractions"] += 1

        if not triples_data and len(text) > 100:
            self._extraction_metrics["empty_extractions"] += 1

        # Fix #7 (hardened): Validate extracted entities actually appear in source text.
        # Rule #16: NO FABRICATED DATA — EVER.
        # Original check only skipped triples where BOTH entities were missing.
        # Hardened check: require BOTH entities present in source text.
        # A triple like (Real Entity, invented_relationship, Fabricated Entity)
        # is still a fabrication even though one entity matches.
        text_lower = text.lower()

        created = []
        for t in triples_data[:30]:
            try:
                subj = t["subject"]
                obj = t["object"]

                # Check if entities are hallucinated (not present in source)
                subj_in_text = subj.lower() in text_lower
                obj_in_text = obj.lower() in text_lower

                if not subj_in_text and not obj_in_text:
                    # BOTH entities missing — clearly hallucinated
                    self._extraction_metrics["hallucinated_entities"] += 1
                    log.debug(
                        "Skipping hallucinated triple (both absent): '%s' and '%s' not in source text",
                        subj, obj,
                    )
                    continue

                if not subj_in_text or not obj_in_text:
                    # ONE entity missing — likely partial hallucination.
                    # Only allow if the missing entity is a short common word
                    # (e.g., predicate objects like "active", "completed")
                    # or if it's a known entity in the registry.
                    missing = subj if not subj_in_text else obj
                    # Allow single-word status/attribute values (common in triples)
                    if " " not in missing.strip() and len(missing) < 20:
                        pass  # Single words are often attribute values, allow
                    else:
                        # Multi-word name not in source — likely fabricated
                        # Check registry as fallback (it might be a known entity)
                        known_entity = False
                        if hasattr(self, "_registry") and self._registry:
                            try:
                                resolved = self._registry.resolve(missing)
                                known_entity = resolved is not None
                            except Exception:
                                pass
                        if not known_entity:
                            self._extraction_metrics["hallucinated_entities"] += 1
                            log.debug(
                                "Skipping partially hallucinated triple: '%s' not in source text and not a known entity",
                                missing,
                            )
                            continue

                triple_id = self.add_triple(
                    subject=subj,
                    predicate=t["predicate"],
                    obj=obj,
                    authority=authority,
                    source_file=source_file,
                    source_date=source_date or _today_iso(),
                    confidence=float(t.get("confidence", 0.8)),
                    subject_type=t.get("subject_type", "concept"),
                    object_type=t.get("object_type", "concept"),
                    knowledge_type=t.get("knowledge_type"),
                )
                created.append({"id": triple_id, **t})

                # Track predicate and knowledge type distribution
                pred = t["predicate"]
                self._extraction_metrics["predicate_distribution"][pred] = \
                    self._extraction_metrics["predicate_distribution"].get(pred, 0) + 1
                kt = t.get("knowledge_type", "declarative")
                self._extraction_metrics["knowledge_type_distribution"][kt] = \
                    self._extraction_metrics["knowledge_type_distribution"].get(kt, 0) + 1

            except (KeyError, TypeError) as e:
                log.debug("Skipping malformed triple: %s", e)
                continue

        self._extraction_metrics["total_triples_extracted"] += len(created)

        if created:
            self.save()
            log.info("Extracted %d triples from %s", len(created), source_file or "text")
            if hasattr(self, "_activity") and self._activity:
                try:
                    subjects = list({t["subject"] for t in created})[:5]
                    self._activity.log("knowledge", "extracted",
                                       f"{len(created)} triples from {source_file or 'conversation'}",
                                       {"count": len(created), "subjects": subjects,
                                        "source": source_file or "conversation"},
                                       importance="medium" if len(created) >= 3 else "low")
                except Exception:
                    pass

        return created

    def get_extraction_metrics(self) -> dict[str, Any]:
        """Return LLM extraction quality metrics (Fix #7).

        Useful for detecting systematic extraction failures:
        - High failed_extractions: LLM returning bad JSON
        - High empty_extractions: LLM missing facts
        - High hallucinated_entities: LLM inventing entities
        - Skewed predicate_distribution: missing relationship types
        """
        m = dict(self._extraction_metrics)
        total = m["total_extractions"]
        if total > 0:
            m["failure_rate"] = m["failed_extractions"] / total
            m["empty_rate"] = m["empty_extractions"] / total
            m["avg_triples_per_extraction"] = m["total_triples_extracted"] / total
        return m

    # ── Authority + Decay ──

    def upgrade_authority(self, source_file: str, new_authority: str) -> int:
        """Upgrade authority on all triples from a given source.

        Called when user approves pending memory — triples become explicit.
        """
        count = 0
        auth_rank = {"inferred": 0, "system": 1, "explicit": 2}
        new_rank = auth_rank.get(new_authority, 0)

        for t in self._triples:
            if source_file in t.get("source_files", []):
                if auth_rank.get(t.get("authority", "inferred"), 0) < new_rank:
                    t["authority"] = new_authority
                    # User approval = verification event (strongest epistemic act)
                    if new_authority == "explicit":
                        t["epistemic_status"] = EpistemicStatus.VERIFIED.value
                        t["last_verified"] = _now_iso()
                    count += 1

        # Upgrade entities too
        for t in self._triples:
            if source_file in t.get("source_files", []):
                for slug in [t["subject"], t.get("object")]:
                    if slug:
                        self._registry.upgrade_authority(slug, new_authority)
                        if new_authority == "explicit":
                            self._registry.mark_verified(slug)

        if count:
            self._dirty = True
            self.save()
            log.info("Upgraded %d triples to authority=%s from %s", count, new_authority, source_file)

        return count

    def merge_entities(self, slug_keep: str, slug_remove: str) -> dict:
        """Merge two entities AND repoint all triple references (Fix #8).

        Previously entity_registry.merge() deleted the old slug but left triples
        still pointing at it, creating orphaned references that returned '?' on query.

        Returns a dict with the merge result plus the data needed by
        ``unmerge_entities`` to reverse the operation:
          - ``removed_snapshot``: full registry dict for slug_remove BEFORE
            destruction (canonical_name, aliases, authority, first_seen, ...)
          - ``repointed_triple_ids``: ids of triples whose subject or object
            was rewritten from slug_remove to slug_keep (one entry per
            triple — a self-referencing triple counts once)
          - ``triples_repointed``: count of edge rewrites (one triple may
            contribute up to 2 — preserved for backward compat with callers
            that displayed the old edge count)

        Audit + decision/activity logging happens at the API layer where
        async is available; this method owns the synchronous graph mutation only.
        """
        # Capture the removed entity's full snapshot BEFORE registry.merge()
        # destroys the row. This is the single source of truth for unmerge
        # restoration — preserves every field (decay_condition, first_seen,
        # access_count, last_verified) the audit columns can't denormalize.
        removed_snapshot = self._registry.get(slug_remove)
        if not removed_snapshot:
            return {"merged": False, "reason": "entities not found"}
        removed_snapshot_copy = dict(removed_snapshot)

        if not self._registry.merge(slug_keep, slug_remove):
            return {"merged": False, "reason": "entities not found"}

        # Repoint triples + track which ones were affected (unique by triple id).
        repointed_edge_count = 0
        repointed_triple_ids: list[int] = []
        for t in self._triples:
            triple_changed = False
            if t["subject"] == slug_remove:
                t["subject"] = slug_keep
                repointed_edge_count += 1
                triple_changed = True
            if t.get("object") == slug_remove:
                t["object"] = slug_keep
                repointed_edge_count += 1
                triple_changed = True
            if triple_changed:
                tid = t.get("id")
                if isinstance(tid, int):
                    repointed_triple_ids.append(tid)

        if repointed_triple_ids:
            self._triples_dirty = True
            self._index.rebuild(self._triples)
            self.save()
        else:
            # Even with zero triple repoints, registry mutated → save anyway
            self.save()

        log.info(
            "Entity merge: '%s' into '%s', %d triple references repointed (%d unique triples)",
            slug_remove, slug_keep, repointed_edge_count, len(repointed_triple_ids),
        )
        return {
            "merged": True,
            "slug_keep": slug_keep,
            "slug_remove": slug_remove,
            "triples_repointed": repointed_edge_count,
            "repointed_triple_ids": repointed_triple_ids,
            "removed_snapshot": removed_snapshot_copy,
        }

    def unmerge_entities(self, audit: dict) -> dict:
        """Reverse a destructive merge using its audit record.

        Required audit fields (matches ``entity_resolution_audit`` row shape):
          ``kept_slug``, ``removed_slug``, ``removed_canonical_name``,
          ``removed_entity_type``, ``removed_aliases``,
          ``repointed_triple_ids``, ``evidence`` (optional —
          ``evidence['removed_entity']`` is preferred for lossless restore).

        Returns:
          - ``restored``: bool — True when the entity was resurrected and
            triple reverses were applied
          - ``triples_restored``: int — count of triples whose subject/object
            was successfully rewritten back from kept_slug to removed_slug
          - ``triples_missing``: int — count of audit'd triple ids that no
            longer exist in the graph (deleted or superseded since the merge)
          - ``warnings``: list[str] — human-readable notes about edge cases
            the user may want to inspect (deleted triples, post-merge
            mutations that left a triple no longer pointing at kept_slug)

        Conservative on edge cases — never blocks the unmerge for them,
        only surfaces them as warnings so the user can decide whether to
        accept the partial restoration.
        """
        if not isinstance(audit, dict):
            return {"restored": False, "reason": "audit must be a dict"}

        kept_slug = str(audit.get("kept_slug") or "")
        removed_slug = str(audit.get("removed_slug") or "")
        if not kept_slug or not removed_slug:
            return {"restored": False, "reason": "audit missing kept_slug/removed_slug"}

        # Prefer the lossless full snapshot stored in evidence['removed_entity'];
        # fall back to the denormalized columns when evidence is empty (e.g.,
        # legacy audit row from a future migration that didn't capture it).
        evidence = audit.get("evidence") or {}
        snapshot: dict | None = None
        if isinstance(evidence, dict):
            es = evidence.get("removed_entity")
            if isinstance(es, dict):
                snapshot = dict(es)
        if snapshot is None:
            snapshot = {
                "canonical_name": audit.get("removed_canonical_name", ""),
                "entity_type": audit.get("removed_entity_type") or "thing",
                "aliases": list(audit.get("removed_aliases") or []),
                "authority": "system",
                "status": "active",
            }
        if not snapshot.get("canonical_name"):
            return {"restored": False, "reason": "audit missing removed_canonical_name"}

        if not self._registry.restore(removed_slug, snapshot):
            return {
                "restored": False,
                "reason": f"slug '{removed_slug}' is in use by a different entity",
            }

        # Build the target id set — accept ints OR digit strings (JSON round-trip
        # may stringify ids depending on the audit storage path).
        raw_ids = audit.get("repointed_triple_ids") or []
        target_ids: set[int] = set()
        for raw in raw_ids:
            if isinstance(raw, int):
                target_ids.add(raw)
            elif isinstance(raw, str) and raw.isdigit():
                target_ids.add(int(raw))

        triples_restored = 0
        warnings: list[str] = []
        for t in self._triples:
            tid = t.get("id")
            if not isinstance(tid, int) or tid not in target_ids:
                continue
            target_ids.discard(tid)
            triple_changed = False
            if t["subject"] == kept_slug:
                t["subject"] = removed_slug
                triple_changed = True
            if t.get("object") == kept_slug:
                t["object"] = removed_slug
                triple_changed = True
            if triple_changed:
                triples_restored += 1
            else:
                warnings.append(
                    f"triple {tid} was on the audit list but no longer "
                    f"references '{kept_slug}' (further mutated since the merge)"
                )

        triples_missing = len(target_ids)
        if triples_missing:
            warnings.append(
                f"{triples_missing} triple(s) from the audit are no longer in "
                f"the graph (deleted or superseded since the merge)"
            )

        # Persist whether or not triples changed — registry was definitely mutated.
        if triples_restored:
            self._triples_dirty = True
            self._index.rebuild(self._triples)
        self.save()

        log.info(
            "Entity unmerge: '%s' resurrected from '%s', %d triples restored, "
            "%d missing, %d warnings",
            removed_slug, kept_slug, triples_restored, triples_missing, len(warnings),
        )
        return {
            "restored": True,
            "kept_slug": kept_slug,
            "removed_slug": removed_slug,
            "triples_restored": triples_restored,
            "triples_missing": triples_missing,
            "warnings": warnings,
        }

    def merge_entities_batch(
        self, pairs: list[tuple[str, str]] | list[dict],
    ) -> dict:
        """Apply many merges in one shot, persisting the graph ONCE at the end.

        The single-merge ``merge_entities`` calls ``self.save()`` after every
        pair, which serialises tens of thousands of triples + entities to JSON
        on every call. On a hot vault (4000+ entities) that's 5-15s per merge
        and the server can crash under contention if a user clicks Apply 30
        times in a row.

        This method runs the entity-registry merges + triple repointings in
        memory for every pair, rebuilds the index ONCE, and saves ONCE. A
        50-pair batch takes roughly the same wall time as one single merge.

        ``pairs`` accepts either ``[(kept, lost), ...]`` tuples or
        ``[{"kept_slug": ..., "lost_slug": ...}, ...]`` dicts so callers can
        pass straight from JSON request bodies.

        Returns ``{merged: int, repointed: int, results: [...], skipped: [...]}``
        with per-pair detail. A bad pair (missing slug, identical slugs)
        ends up in ``skipped`` and never blocks the rest of the batch.
        """
        if not pairs:
            return {"merged": 0, "repointed": 0, "results": [], "skipped": []}

        # Normalize input shape so callers can pass either tuples or dicts.
        normalized: list[tuple[str, str]] = []
        for entry in pairs:
            if isinstance(entry, dict):
                k = str(entry.get("kept_slug", "")).strip()
                r = str(entry.get("lost_slug", "")).strip()
            else:
                k = str(entry[0]).strip() if len(entry) >= 1 else ""
                r = str(entry[1]).strip() if len(entry) >= 2 else ""
            normalized.append((k, r))

        results: list[dict] = []
        skipped: list[dict] = []
        total_repointed = 0
        # Track which lost_slugs have already been merged so chained merges
        # in one batch (A->B, B->C) work — the second pair sees B as the
        # already-merged-into name, but we still want the triple repoint
        # to land on C. We do this naively here: each pair runs sequentially
        # against the live registry; users typically pick non-overlapping
        # pairs from the proposals list, so this is rare.
        for kept_slug, lost_slug in normalized:
            if not kept_slug or not lost_slug:
                skipped.append({
                    "kept_slug": kept_slug,
                    "lost_slug": lost_slug,
                    "reason": "missing_slug",
                })
                continue
            if kept_slug == lost_slug:
                skipped.append({
                    "kept_slug": kept_slug,
                    "lost_slug": lost_slug,
                    "reason": "same_slug",
                })
                continue

            # Capture the BEFORE snapshot per pair so the route handler can
            # write one audit row per merge for non-destructive reversibility.
            # Same shape as the single-merge ``merge_entities`` result.
            removed_snapshot = self._registry.get(lost_slug)
            removed_snapshot_copy = dict(removed_snapshot) if removed_snapshot else {}

            if not self._registry.merge(kept_slug, lost_slug):
                skipped.append({
                    "kept_slug": kept_slug,
                    "lost_slug": lost_slug,
                    "reason": "entities_not_found",
                })
                continue

            repointed = 0
            repointed_triple_ids: list[int] = []
            for t in self._triples:
                triple_changed = False
                if t["subject"] == lost_slug:
                    t["subject"] = kept_slug
                    repointed += 1
                    triple_changed = True
                if t.get("object") == lost_slug:
                    t["object"] = kept_slug
                    repointed += 1
                    triple_changed = True
                if triple_changed:
                    tid = t.get("id")
                    if isinstance(tid, int):
                        repointed_triple_ids.append(tid)

            total_repointed += repointed
            results.append({
                "kept_slug": kept_slug,
                "lost_slug": lost_slug,
                "triples_repointed": repointed,
                "repointed_triple_ids": repointed_triple_ids,
                "removed_snapshot": removed_snapshot_copy,
            })

        # Single index rebuild + save at the end — the whole point of this method.
        if results:
            self._triples_dirty = True
            self._index.rebuild(self._triples)
            self.save()

        log.info(
            "Batch entity merge: %d merged, %d skipped, %d total triple repoints",
            len(results), len(skipped), total_repointed,
        )
        return {
            "merged": len(results),
            "repointed": total_repointed,
            "results": results,
            "skipped": skipped,
        }

    # ── Merge dismissals (persisted) ──
    #
    # When the user reviews a proposal and rejects it, we store the pair
    # so it doesn't re-surface on the next scan. Storage is a tiny JSON
    # file in the vault — no DB table, no migration. Keys are the lossless
    # tuple ``[slug_a, slug_b]`` always sorted, so direction-flipped
    # proposals collide cleanly.

    def load_dismissed_merges(self) -> set[tuple[str, str]]:
        """Read the dismissed-merges set from disk. Returns an empty set
        on first run, missing file, or read error.

        Reads the file directly (NOT via VaultReader.read) because that
        helper parses leading ``{`` as YAML frontmatter and returns empty
        content for JSON files. Same workaround used by
        ``KnowledgeBaseService._read_compile_state`` for the same reason.
        """
        try:
            path = (
                self._vault.paths.resolve(_DISMISSED_MERGES_PATH)
                if getattr(self._vault, "paths", None) is not None
                else self._vault._vault_dir / _DISMISSED_MERGES_PATH
            )
            if not path.exists():
                return set()
            raw = path.read_text(encoding="utf-8")
            if not raw.strip():
                return set()
            data = json.loads(raw)
            if not isinstance(data, list):
                return set()
            out: set[tuple[str, str]] = set()
            for entry in data:
                if isinstance(entry, list) and len(entry) == 2 \
                        and isinstance(entry[0], str) and isinstance(entry[1], str):
                    a, b = sorted([entry[0], entry[1]])
                    out.add((a, b))
            return out
        except Exception:
            log.debug(
                "graph: dismissed_merges.json load failed", exc_info=True,
            )
            return set()

    def save_dismissed_merges(self, dismissed: set[tuple[str, str]]) -> None:
        """Persist the dismissed-merges set to disk. Sorted for readable
        diffs. Always uses ``trust_reason="graph_persist"`` (same as
        triples.json) — these writes are bookkeeping side-effects of
        already-authorized review actions, not user-facing decisions
        in their own right."""
        ordered = sorted(
            (sorted([a, b]) for a, b in dismissed),
            key=lambda p: (p[0], p[1]),
        )
        try:
            self._writer.write(
                _DISMISSED_MERGES_PATH,
                json.dumps(ordered, indent=2, ensure_ascii=False),
                trust_reason="graph_persist",
            )
        except Exception:
            log.debug(
                "graph: dismissed_merges.json save failed", exc_info=True,
            )

    def add_dismissed_merge(self, slug_a: str, slug_b: str) -> bool:
        """Mark a proposal pair as dismissed. Returns True if newly added,
        False if it was already dismissed. Idempotent."""
        if not slug_a or not slug_b or slug_a == slug_b:
            return False
        dismissed = self.load_dismissed_merges()
        a, b = sorted([slug_a, slug_b])
        if (a, b) in dismissed:
            return False
        dismissed.add((a, b))
        self.save_dismissed_merges(dismissed)
        return True

    # ── Merge proposals (suggest-only) ──
    #
    # find_merge_proposals scans the registry for likely-duplicate entity
    # pairs. It is a PURE READ — no entity, triple, status, authority, or
    # weight is mutated by calling it. The intent is to surface candidates
    # to a human-approved review surface (mirroring memory/pending) so the
    # user explicitly sanctions every merge before merge_entities() runs.
    #
    # Two conservative rules ship by default:
    #
    #   1. Comma-prefix rule (high confidence): one entity's canonical name
    #      is exactly the prefix of another's name UP TO the first comma —
    #      e.g. "Abby Okunowo" + "Abby Okunowo, Program Coordinator at CEATI".
    #      News-prose attribution split into two entities by a sloppy LLM run.
    #      The shorter name is kept; the longer one is the duplicate.
    #
    #   2. Embedding cosine ≥ 0.95 (high confidence): same canonical-name
    #      semantic family. Threshold deliberately conservative — modern
    #      multilingual embeddings put close-but-distinct entities (Cathay
    #      Pacific vs Cathay Dragon, Sarah Chen vs Sarah Lee) at 0.85–0.93,
    #      so 0.95 is unlikely to false-positive. The kept slug is chosen
    #      by entity registry ordering (older slug wins) which is a stable,
    #      auditable rule.
    #
    # Pairs with one entity already dismissed (see dismissed_merges) are
    # filtered out so a once-rejected proposal does not re-surface.

    def find_merge_proposals(
        self,
        *,
        embedding_threshold: float = 0.95,
        max_pairs: int = 200,
        dismissed: set[tuple[str, str]] | None = None,
    ) -> list[dict]:
        """Suggest pairs of entities that look like duplicates.

        Pure read — no graph mutation. Returns up to ``max_pairs`` proposals
        sorted by confidence descending, each with shape:

            {
                "kept_slug": str,
                "lost_slug": str,
                "kept_name": str,
                "lost_name": str,
                "rule": "comma_prefix" | "embedding_cosine",
                "confidence": float,
                "details": {... rule-specific evidence ...},
            }

        Args:
            embedding_threshold: minimum cosine for the embedding rule.
                0.95 is conservative on purpose — close-but-distinct entities
                live in the 0.85–0.93 range in modern multilingual models.
            max_pairs: cap on returned proposals to keep the review surface
                navigable. Hits are added in this order:
                comma-prefix first (deterministic, always correct), then
                embedding (probabilistic, ranked by score descending).
            dismissed: set of ``(slug_a, slug_b)`` pairs (order-independent —
                both directions checked) that have been rejected and must
                not re-appear. Pass the loaded dismissals from disk.
        """
        import math

        registry = self._registry
        entities = list(registry.entities.items())  # snapshot
        if len(entities) < 2:
            return []

        dismissed = dismissed or set()

        def _is_dismissed(a: str, b: str) -> bool:
            return (a, b) in dismissed or (b, a) in dismissed

        proposals: list[dict] = []
        seen_pairs: set[tuple[str, str]] = set()

        # ── Rule 1: comma-prefix ─────────────────────────────────────────
        # Build a name → list[slug] map so we can find candidates whose
        # name starts with another entity's name + ",".
        name_by_slug: dict[str, str] = {}
        for slug, ent in entities:
            cn = ent.get("canonical_name") or ""
            if cn:
                name_by_slug[slug] = cn

        # For every entity, check if its name appears as a comma-prefix
        # of another entity's name. The shorter wins.
        for slug_short, name_short in name_by_slug.items():
            if not name_short or len(name_short) < 2:
                continue
            prefix = name_short + ","
            for slug_long, name_long in name_by_slug.items():
                if slug_long == slug_short:
                    continue
                if not name_long.startswith(prefix):
                    continue
                # Type compatibility: only propose merge if types are
                # compatible. Same type wins. "thing"/"concept" treated as
                # untyped and merges with anything.
                ent_short = registry.entities.get(slug_short, {})
                ent_long = registry.entities.get(slug_long, {})
                t_short = (ent_short.get("entity_type") or "").lower()
                t_long = (ent_long.get("entity_type") or "").lower()
                untyped = {"", "thing", "concept"}
                if t_short and t_long and t_short != t_long \
                        and t_short not in untyped and t_long not in untyped:
                    continue
                pair = tuple(sorted([slug_short, slug_long]))
                if pair in seen_pairs:
                    continue
                if _is_dismissed(slug_short, slug_long):
                    continue
                seen_pairs.add(pair)
                proposals.append({
                    "kept_slug": slug_short,
                    "lost_slug": slug_long,
                    "kept_name": name_short,
                    "lost_name": name_long,
                    "rule": "comma_prefix",
                    "confidence": 0.95,
                    "details": {
                        "kept_length": len(name_short),
                        "lost_length": len(name_long),
                        "kept_type": ent_short.get("entity_type") or "",
                        "lost_type": ent_long.get("entity_type") or "",
                    },
                })
                if len(proposals) >= max_pairs:
                    return proposals

        # ── Rule 2: embedding cosine ────────────────────────────────────
        # Only run if the embedding cache has entries. We do NOT trigger
        # backfill here — the user runs that separately. find_merge_proposals
        # operates on whatever is currently cached and degrades silently
        # to comma-prefix only when no embeddings are available.
        emb_cache = getattr(registry, "_embedding_cache", {})
        if not emb_cache:
            return proposals

        # Pre-compute norms for each cached embedding (skip near-zero).
        norms: dict[str, tuple[list[float], float]] = {}
        for slug, emb in list(emb_cache.items()):
            emb_list = list(emb) if not isinstance(emb, list) else emb
            sq = 0.0
            for x in emb_list:
                sq += float(x) * float(x)
            if sq < 1e-18:
                continue
            norms[slug] = (emb_list, math.sqrt(sq))

        cached_slugs = list(norms.keys())
        # O(n^2) over cached entities. With max_pairs as a hard ceiling and
        # the registry size typically in the hundreds, this is fine. If a
        # vault grows past ~5k cached entities the cost would be ~12M cosine
        # ops — still seconds, but at that scale we'd want LSH instead.
        emb_hits: list[tuple[float, str, str]] = []
        for i, slug_a in enumerate(cached_slugs):
            ea, na = norms[slug_a]
            for slug_b in cached_slugs[i + 1:]:
                pair = tuple(sorted([slug_a, slug_b]))
                if pair in seen_pairs:
                    continue
                if _is_dismissed(slug_a, slug_b):
                    continue
                eb, nb = norms[slug_b]
                if len(ea) != len(eb):
                    continue
                dot = 0.0
                for x, y in zip(ea, eb, strict=False):
                    dot += float(x) * float(y)
                cos = dot / (na * nb)
                if cos >= embedding_threshold:
                    emb_hits.append((cos, slug_a, slug_b))

        # Rank hits by similarity descending.
        emb_hits.sort(key=lambda r: r[0], reverse=True)

        for cos, slug_a, slug_b in emb_hits:
            if len(proposals) >= max_pairs:
                break
            ent_a = registry.entities.get(slug_a, {})
            ent_b = registry.entities.get(slug_b, {})
            t_a = (ent_a.get("entity_type") or "").lower()
            t_b = (ent_b.get("entity_type") or "").lower()
            untyped = {"", "thing", "concept"}
            if t_a and t_b and t_a != t_b \
                    and t_a not in untyped and t_b not in untyped:
                continue
            # Pick keeper: prefer higher authority, then shorter name, then
            # alphabetical slug — deterministic and auditable.
            authority_rank = {"explicit": 3, "system": 2, "inferred": 1}
            auth_a = authority_rank.get(ent_a.get("authority", "inferred"), 1)
            auth_b = authority_rank.get(ent_b.get("authority", "inferred"), 1)
            if auth_a != auth_b:
                kept = slug_a if auth_a > auth_b else slug_b
            else:
                name_a = ent_a.get("canonical_name") or slug_a
                name_b = ent_b.get("canonical_name") or slug_b
                if len(name_a) != len(name_b):
                    kept = slug_a if len(name_a) < len(name_b) else slug_b
                else:
                    kept = slug_a if slug_a < slug_b else slug_b
            lost = slug_b if kept == slug_a else slug_a
            pair = tuple(sorted([slug_a, slug_b]))
            seen_pairs.add(pair)
            proposals.append({
                "kept_slug": kept,
                "lost_slug": lost,
                "kept_name": registry.entities.get(kept, {}).get(
                    "canonical_name", kept,
                ),
                "lost_name": registry.entities.get(lost, {}).get(
                    "canonical_name", lost,
                ),
                "rule": "embedding_cosine",
                "confidence": float(cos),
                "details": {
                    "cosine": float(cos),
                    "kept_type": ent_a.get("entity_type") or "" if kept == slug_a else ent_b.get("entity_type") or "",
                    "lost_type": ent_b.get("entity_type") or "" if kept == slug_a else ent_a.get("entity_type") or "",
                },
            })

        return proposals

    def evaluate_decay(self) -> dict:
        """Evaluate decay conditions on all entities and triples."""
        now = datetime.now(UTC)
        entities_decayed = 0
        triples_decayed = 0

        for t in self._triples:
            if t["status"] != "active":
                continue
            condition = t.get("decay_condition", "never")
            if self._should_decay(condition, t, now):
                t["status"] = "decayed"
                t["decayed_at"] = _now_iso()
                triples_decayed += 1

        # Decay entities based on their own decay condition.
        # The entity decays if its condition is met — the cascade code below
        # handles individual triples selectively (verified/recent triples survive).
        # Previously required ALL triples to be decayed first, which prevented
        # entity decay when triples had "when:superseded" (never met) conditions.
        newly_decayed_entities: list[str] = []
        for slug, ent in self._registry.entities.items():
            if ent.get("status") == "decayed":
                continue
            condition = ent.get("decay_condition", "never")
            if condition == "never":
                continue
            if self._should_decay(condition, ent, now):
                self._registry.mark_decayed(slug)
                entities_decayed += 1
                newly_decayed_entities.append(slug)

        # Cascade: when an entity decays, decay its triples — BUT skip triples
        # that are individually verified or recently accessed (Fix #5).
        # Previously this was all-or-nothing: if "Project X" entity decayed,
        # even "Project X deadline is June 2026" (actively queried yesterday) died.
        # Build entity→triple index to avoid O(entities × triples) scan.
        if newly_decayed_entities:
            entity_triples: dict[str, list[dict]] = {}
            for t in self._triples:
                if t["status"] != "active":
                    continue
                for slug_key in (t["subject"], t.get("object")):
                    if slug_key:
                        entity_triples.setdefault(slug_key, []).append(t)

        for slug in newly_decayed_entities:
            for t in entity_triples.get(slug, []):
                if t["status"] != "active":
                    continue  # may have been decayed by a previous slug in this loop
                # Skip verified triples — they have independent epistemic ground
                if t.get("epistemic_status") == "verified":
                    continue
                # Skip triples accessed in the last 14 days — still actively relevant
                last_acc = t.get("last_accessed", "")
                if last_acc:
                    try:
                        acc_dt = datetime.fromisoformat(last_acc.replace("Z", "+00:00"))
                        if (now - acc_dt) < timedelta(days=14):
                            continue
                    except ValueError:
                        pass
                t["status"] = "decayed"
                t["decayed_at"] = _now_iso()
                triples_decayed += 1

        if entities_decayed or triples_decayed:
            self._dirty = True
            self.save()
            log.info("Decay: %d entities, %d triples decayed", entities_decayed, triples_decayed)

        return {"entities_decayed": entities_decayed, "triples_decayed": triples_decayed}

    @staticmethod
    def _should_decay(condition: str, item: dict, now: datetime) -> bool:
        """Check if a decay condition is met."""
        if not condition or condition == "never":
            return False

        parts = condition.split(":")

        if parts[0] == "after" and len(parts) >= 2:
            days = int(parts[1].rstrip("d"))
            created = item.get("first_seen") or item.get("source_date", "")
            if created:
                try:
                    created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
                    return (now - created_dt) > timedelta(days=days)
                except ValueError:
                    pass

        elif parts[0] == "when" and len(parts) >= 2:
            if parts[1] == "superseded":
                return bool(item.get("superseded_by"))
            if parts[1] == "unaccessed" and len(parts) >= 3:
                days = int(parts[2].rstrip("d"))
                last = item.get("last_accessed", "")
                if last:
                    try:
                        last_dt = datetime.fromisoformat(last.replace("Z", "+00:00"))
                        return (now - last_dt) > timedelta(days=days)
                    except ValueError:
                        pass

        return False

    def reweight(self) -> None:
        """Recalculate effective weights for all active triples.

        Epistemically sound formula (Phase 6F):
          effective_weight =
              authority × confidence × epistemic × temporal × justification × affective

        where:
          justification = (1 + source_boost) × inference_factor
          affective     = 1.0 + (affective_weight × 0.5)   # 1.0..1.5 multiplier

        Key design principles:
        1. Temporal factor uses last_verified (not last_accessed). Reading a fact
           doesn't make it more true — only verification events reset the clock.
        2. Independent sources (horizontal) boost weight: +0.1 per source, capped +0.5.
           Inference chains (vertical) penalize: 0.95^depth — long chains are fragile.
        3. Epistemic factors treat verified (1.0) as the baseline, not believed.
           Unverified beliefs are penalized because they lack justification.
        4. Verified facts that decay below temporal=0.15 get downgraded to believed
           (reconciles the verified+decayed contradiction).
        5. **Phase 6F**: ``affective_weight`` is a personal-salience multiplier
           in the [1.0, 1.5] range (neutral → maximum affect). Triples about
           emotionally significant people, events, or commitments rank higher
           in queries than logistical trivia at the same epistemic level.
           Cap is 1.5x so affect can never override authority — a strongly
           emotional belief still loses to a user-confirmed fact. Triples
           with affective_weight=0.0 (the default for non-hippocampal-derived
           triples) get multiplier=1.0 → no behavior change → backward compat.
        """
        now = datetime.now(UTC)

        for t in self._triples:
            if t["status"] != "active":
                continue

            auth_w = AUTHORITY_WEIGHTS.get(t.get("authority", "inferred"), 1.0)

            # Bayesian confidence: use 25th percentile (lower quartile)
            # when Beta params are available. More evidence → tighter CI
            # → higher lower quartile → higher effective weight. The 25th
            # percentile is conservative without cratering the weight.
            _ca = t.get("conf_alpha")
            _cb = t.get("conf_beta")
            if _ca is not None and _cb is not None and (_ca + _cb) > 0:
                from agntspace.infra.bayesian import beta_ppf
                confidence = beta_ppf(0.25, _ca, _cb)
            else:
                confidence = t.get("confidence", 0.8)

            # Fix 6: Verified is the epistemic baseline (1.0), not a bonus.
            # Unverified beliefs are penalized because they lack full justification.
            epistemic_factors = {
                "verified": 1.0, "believed": 0.7, "uncertain": 0.5,
                "contested": 0.3, "superseded": 0.1, "retracted": 0.0,
            }
            epistemic = epistemic_factors.get(t.get("epistemic_status", "believed"), 0.7)

            # Fix 1 + 4: Temporal factor uses last_verified, not last_accessed.
            # Justification comes from evidence, not retrieval frequency.
            # Half-life of 90 days (verified facts stay strong longer).
            # Falls back to source_date for never-verified facts (shorter 45-day half-life).
            # Temporal factor: knowledge-type-aware half-lives.
            # Episodic facts (events) fade fast; normative (preferences) persist.
            # Verified facts get the full half-life; unverified get 50% of it.
            kt = t.get("knowledge_type", "declarative")
            half_life = _load_memory_policy()["half_lives"].get(kt, 90)

            temporal = 1.0
            verified_at = t.get("last_verified")
            if verified_at:
                try:
                    verified_dt = datetime.fromisoformat(verified_at.replace("Z", "+00:00"))
                    if verified_dt.tzinfo is None:
                        verified_dt = verified_dt.replace(tzinfo=UTC)
                    days_since = max(0, (now - verified_dt).days)
                    temporal = 0.5 ** (days_since / half_life)
                except ValueError:
                    pass
            else:
                # Unverified: 50% of the knowledge-type half-life
                unverified_half_life = max(14, half_life // 2)
                source = t.get("source_date") or t.get("first_seen", "")
                if source:
                    try:
                        source_dt = datetime.fromisoformat(source.replace("Z", "+00:00"))
                        if source_dt.tzinfo is None:
                            source_dt = source_dt.replace(tzinfo=UTC)
                        days_since = max(0, (now - source_dt).days)
                        temporal = 0.5 ** (days_since / unverified_half_life)
                    except ValueError:
                        pass

            # Fix #6: Separate independent sources (horizontal credibility) from
            # inference chains (vertical reasoning). Multiple independent sources
            # confirming a fact is genuinely robust. Long inference chains are fragile
            # — break one hop and everything downstream collapses.
            #
            # Sources boost: each independent source adds +0.1 (capped at +0.5)
            # Inference penalty: each hop in the chain REDUCES confidence slightly
            # because inferred facts are only as strong as their weakest premise.
            source_count = len(t.get("source_files", []))
            source_boost = min(0.5, source_count * 0.1)

            justified_by = t.get("justified_by", [])
            inference_depth = len(justified_by)
            # Inferred facts (depth > 0) get penalized: 0.95^depth
            # 1 hop = 0.95, 2 hops = 0.90, 5 hops = 0.77, 10 hops = 0.60
            inference_factor = 0.95 ** inference_depth if inference_depth > 0 else 1.0

            justification = (1.0 + source_boost) * inference_factor

            # Phase 6F: affective weighting. Bound the multiplier to
            # [1.0, 1.5] so neutral-affect triples are unchanged and
            # strong-affect triples get a meaningful but bounded boost.
            try:
                aw = float(t.get("affective_weight", 0.0) or 0.0)
            except (TypeError, ValueError):
                aw = 0.0
            aw = max(0.0, min(1.0, aw))
            affective = 1.0 + (aw * 0.5)

            t["effective_weight"] = (
                auth_w * confidence * epistemic * temporal * justification * affective
            )

            # Fix #3: Reconcile verified status with severe temporal decay.
            # A fact can't be both "verified" and temporally irrelevant — if temporal
            # factor drops below 0.15 (roughly 2.5+ half-lives without re-verification),
            # downgrade to "believed" so queries know it needs re-confirmation.
            if t.get("epistemic_status") == "verified" and temporal < 0.15:
                t["epistemic_status"] = "believed"
                log.debug(
                    "Triple #%d downgraded verified->believed (temporal=%.3f, "
                    "last_verified=%s -- needs re-confirmation)",
                    t.get("id", 0), temporal, t.get("last_verified", "?"),
                )

            # Fix C: Recover uncertain triples that have been re-encountered.
            # When a triple is marked uncertain by cascade, it stays uncertain forever
            # even if the fact is re-mentioned later. If the triple has been
            # re-encountered since becoming uncertain (encounter_count > 0 and
            # multiple sources corroborate it), upgrade back to believed.
            if t.get("epistemic_status") == "uncertain":
                sources = len(t.get("source_files", []))
                encounters = t.get("encounter_count", 0)
                # Recovery: 2+ independent sources, or 3+ re-encounters
                if sources >= 2 or encounters >= 3:
                    t["epistemic_status"] = "believed"
                    log.debug(
                        "Triple #%d recovered uncertain->believed "
                        "(sources=%d, encounters=%d)",
                        t.get("id", 0), sources, encounters,
                    )

        self._dirty = True

    # ── Wiki-link graph building ──

    def build(
        self,
        on_progress: Callable[[int, int], None] | None = None,
        *,
        force_full: bool = False,
    ) -> int:
        """Scan vault files, build graphs from [[links]] + SPO overlay + cross-refs.

        Phase 3 of the incremental-build refactor — see
        ``tasks/plan-incremental-graph-build.md``. ``build()`` now uses a
        persistent ``BuildIndex`` (``memory/graph/build_index.json``) to
        skip re-reading unchanged files between server restarts. The
        first boot after upgrade does one full rebuild and writes the
        index; subsequent boots take only the delta.

        Modes:

        - **Full rebuild** — clear graphs, scan every file. Triggered by:
          ``force_full=True``, env var
          ``AGNTSPACE_GRAPH_INCREMENTAL=0``, no usable index on disk
          (first boot or load failure), or change-ratio > 30% (so we
          don't pretend an "almost full" rebuild is incremental).
        - **Incremental** — keep the in-memory graph state from
          ``_load_graph_data`` + the post-loop overlays (which rebuild
          edges from triples), then scan only added + modified files
          and retract deleted ones.

        Known Phase 3 limitation: incremental mode does NOT restore
        wiki-link-graph state for *unchanged* files. The SPO graph IS
        fully restored from triples via ``_overlay_spo_edges``, but raw
        ``[[link]]`` co-mention edges from journal / observation files
        without typed entities are lost until the next full rebuild.
        Phase 4 closes this gap by persisting a ``wikilink_index.json``
        sidecar. For now, ``POST /api/graph/build`` triggers a full
        rebuild whenever the user wants the wiki-link graph fully
        populated.

        If ``on_progress`` is provided, it's called with ``(scanned,
        total)`` at intervals (every 1000 files + once at completion).
        In incremental mode the totals refer to the delta size, not
        the full vault.

        Crash safety: snapshot the prior state and restore on any
        exception. The clear-and-replace stays atomic from the API's
        perspective.
        """
        # Phase 6 — observability: timing + mode capture for the post-build summary
        _build_started_at_iso = _now_iso()
        _build_started_perf = time.monotonic()

        _prior_wikilink = self._wikilink_graph.copy() if self._wikilink_graph.number_of_nodes() else None
        _prior_spo = self._spo_graph.copy() if self._spo_graph.number_of_nodes() else None
        _prior_combined = self._graph.copy() if self._graph.number_of_nodes() else None
        _prior_node_sources = dict(self._node_sources) if self._node_sources else None
        _prior_article_triples = dict(self._article_triples) if self._article_triples else None
        _prior_article_entities = dict(self._article_entities) if self._article_entities else None

        def _restore_prior_state() -> None:
            """Put the previous graph state back when the build aborts."""
            if _prior_wikilink is not None:
                self._wikilink_graph = _prior_wikilink
            if _prior_spo is not None:
                self._spo_graph = _prior_spo
            if _prior_combined is not None:
                self._graph = _prior_combined
            if _prior_node_sources is not None:
                self._node_sources = _prior_node_sources
            if _prior_article_triples is not None:
                self._article_triples = _prior_article_triples
            if _prior_article_entities is not None:
                self._article_entities = _prior_article_entities

        # Capture the restorer on self so the except/cancel handlers below
        # can reach it. We can't just try/except around the whole body because
        # build() is 190 lines and touches self._graph throughout — wrapping
        # at the entry point is the surgical option. Callers that want the
        # old behavior (full teardown on failure) can explicitly call
        # `graph.reset()` before build, but we have no such callers today.
        self.__build_restorer = _restore_prior_state  # noqa: SLF001

        # During bulk graph build we resolve thousands of wikilinks against
        # the registry. Tier 3 (embedding cosine similarity) would fire for
        # every single link, hitting Ollama for a ~1024-dim vector each time.
        # On a 5k-entity vault that's tens of thousands of network round-trips
        # — hours of blocking work AND sustained CPU pressure that pushes the
        # whole process toward OOM. The embedding tier is the RIGHT choice for
        # ambiguous freeform input (chat-turn entity extraction, dream phases)
        # but wrong for wikilinks — those are stable identifiers the user
        # typed deliberately, so the string tiers (exact / alias / sequence
        # matcher) are both sufficient AND 100× faster. Disable the tier for
        # the duration of build() and restore after so other paths keep using
        # it normally. Saved/restored around _load_graph_data + the scan
        # because _overlay_structural_triples and structural resolves ALSO
        # hit registry.resolve.
        _prior_embed_disabled = self._registry._embedding_disabled  # noqa: SLF001
        _prior_fuzzy_disabled = self._registry._fuzzy_disabled  # noqa: SLF001
        self._registry._embedding_disabled = True  # noqa: SLF001
        # Same rationale as embedding disable above, but for Tier 4
        # SequenceMatcher: bulk build processes thousands of wikilinks
        # against a registry that can have 7000+ entities; each Tier 4
        # call iterates ALL entities × name_length² and pins one CPU
        # core for minutes with zero progress (live-measured 2026-04-25:
        # 463 new files × ~50 links each = 23K resolves × 14K entity
        # comparisons each = 322M SequenceMatcher.ratio() calls,
        # indistinguishable from a deadlock). The wikilinks being
        # processed here were typed deliberately by the user against
        # registered entities — Tier 1 (exact slug) and Tier 2 (alias)
        # cover every realistic case. Tier 3/4 are for ambiguous
        # freeform input (chat extraction, dream phases) and stay
        # active for those callers via the restore in finally below.
        self._registry._fuzzy_disabled = True  # noqa: SLF001

        # Load persisted data
        self._load_graph_data()

        # Self-heal: if the registry is empty (first boot, wiped entities.json,
        # or failed load), rebuild it from scanned file frontmatter before the
        # wikilink scan runs — otherwise wikilink canonicalization can't work.
        if not self._registry.entities:
            rebuilt = self.rebuild_registry_from_files()
            if rebuilt:
                log.info("Registry auto-rebuilt from files: %d entities", rebuilt)

        # ----- Phase 3: feature flag + index load -----
        # The flag is read at every build() call so tests can flip it via
        # monkeypatch. Treat absent / blank / falsy values as on; only
        # explicit "0"/"false"/"no"/"off" disables incremental mode.
        flag_raw = os.environ.get("AGNTSPACE_GRAPH_INCREMENTAL", "1").strip().lower()
        incremental_enabled = flag_raw not in ("0", "false", "no", "off")

        index: BuildIndex | None = None
        # Capture whether build_index.json was MISSING vs LOADED-AND-REJECTED.
        # The load() method backs up corrupt indexes (renames the file) as
        # part of recovery, so checking ``index_path.is_file()`` after load
        # can't distinguish "never existed" from "existed but rejected and
        # renamed away during load". We need that distinction below to
        # decide whether the wikilink_index.json watermark is trustworthy.
        index_was_missing = False
        if incremental_enabled:
            index_path = self._resolve_index_path()
            if index_path is not None:
                index_was_missing = not index_path.is_file()
                try:
                    index = BuildIndex(index_path).load()
                except Exception:  # pragma: no cover — defensive
                    log.exception("BuildIndex load failed — falling back to full rebuild")
                    index = None

        # ----- Phase 3: stat sweep + filter (cheap, no file reads) -----
        files = self._vault.list_files(pattern="**/*.md")
        current_paths, files_by_path = self._stat_filtered_files(files)

        # ----- Fallback watermark (added 2026-04-25, fix #2) -----
        # When build_index.json is genuinely MISSING (file does not exist),
        # but wikilink_index.json IS present and loaded into
        # self._wikilink_index, use the snapshot's mtime as a "skip files
        # older than this" hint. The snapshot was written at the end of a
        # previous successful build OR by _save_wikilink_index() during
        # steady-state operation, so every file in the snapshot whose
        # mtime is ≤ the snapshot's mtime is already represented in the
        # in-memory state we just loaded via _load_graph_data +
        # _load_wikilink_index. Seeding those files into the BuildIndex
        # with their current stat lets diff() classify them as "unchanged"
        # — turning the next pass into an incremental scan of just newer
        # files.
        #
        # CRITICAL: only fire this when the file is MISSING, not when
        # build_index.json was rejected (corrupt / schema mismatch /
        # fingerprint mismatch). Those rejections signal that the
        # extraction logic changed — wikilink_index.json may be stale
        # too, so the safe path is a full rebuild (existing behavior).
        # The MISSING case is the kill-loop victim — the index never got
        # written because every prior build was killed before completing,
        # but wikilink_index.json was saved separately by routine
        # operations and is still trustworthy.
        if (
            incremental_enabled
            and index is not None
            and index.is_fresh
            and self._wikilink_index
            and index_path is not None
            and index_was_missing  # MISSING from the start — not "loaded but rejected then renamed"
        ):
            try:
                wl_path = self._resolve_wikilink_index_path()
                if wl_path is not None and wl_path.is_file():
                    watermark_ns = wl_path.stat().st_mtime_ns
                    seeded = 0
                    snapshot_paths = set(self._wikilink_index.keys())
                    for relative, stat in current_paths.items():
                        if relative not in snapshot_paths:
                            continue
                        if stat.mtime_ns > watermark_ns:
                            continue  # file modified after the snapshot — re-scan
                        # File represented in the snapshot AND not modified
                        # since — record it as already-processed so diff()
                        # classifies it as unchanged.
                        try:
                            index.record_processed(relative, stat, FileContribution())
                            seeded += 1
                        except Exception:
                            pass  # bookkeeping should never break the build
                    if seeded > 0:
                        index.is_fresh = False  # we now have usable state — incremental can fire
                        log.info(
                            "Graph: seeded %d files into build index from wikilink_index.json watermark "
                            "(snapshot age %.0fs). Next pass will only re-scan files newer than the snapshot.",
                            seeded,
                            (time.time() - watermark_ns / 1e9),
                        )
            except Exception:
                # Watermark seeding is opportunistic — any failure just means we
                # fall back to full rebuild, exactly as if the watermark wasn't
                # available. Never break the build for a watermark hiccup.
                log.debug("Watermark seeding failed (non-fatal) — falling back to full rebuild", exc_info=True)

        # ----- Phase 3: decide mode -----
        delta = None
        use_incremental = (
            incremental_enabled
            and not force_full
            and index is not None
            and not index.is_fresh
        )
        if use_incremental:
            delta = index.diff(current_paths)
            denom = max(1, len(current_paths))
            change_ratio = (len(delta.added) + len(delta.modified)) / denom
            if change_ratio > 0.3:
                log.info(
                    "Graph: %.0f%% of files changed — falling back to full rebuild",
                    change_ratio * 100,
                )
                use_incremental = False
                delta = None
            elif index.files and not self._wikilink_index:
                # Inconsistent state: build_index has entries but the
                # wiki-link snapshot is empty (corrupt / version-mismatched
                # / accidentally deleted). Phase 4 projection would silently
                # do nothing and we'd boot with empty graphs. Fall back to a
                # full rebuild so both indices come back into sync.
                log.warning(
                    "Graph: build_index has %d entries but wikilink_index is empty — full rebuild to resync",
                    len(index.files),
                )
                use_incremental = False
                delta = None

        # ----- Phase 3: execute the chosen branch -----
        edges_created = 0
        scanned_paths: set[str] = set()

        # Diagnostic instrumentation (added 2026-04-25): every major
        # post-load step logs ENTRY+EXIT so the last log line before
        # silence in a hung server pinpoints the runaway. Removed once
        # the loop bug is found and fixed; kept short to stay readable.
        log.info("[build-trace] entering use_incremental=%s, delta=%s files added/%s modified/%s deleted/%s unchanged",
                 use_incremental,
                 len(delta.added) if delta else "—",
                 len(delta.modified) if delta else "—",
                 len(delta.deleted) if delta else "—",
                 len(delta.unchanged) if delta else "—")

        if use_incremental and delta is not None:
            log.info("[build-trace] step=project_wikilink_index BEGIN (snapshot has %d files)", len(self._wikilink_index))
            edges_created = self._project_wikilink_index()
            log.info("[build-trace] step=project_wikilink_index END (edges=%d)", edges_created)

            log.info("[build-trace] step=process_deletions BEGIN (%d files)", len(delta.deleted))
            for path in delta.deleted:
                self._retract_file_contributions(path)
                index.record_deleted(path)
            log.info("[build-trace] step=process_deletions END")

            files_to_scan = [files_by_path[p] for p in (delta.added + delta.modified)]
            log.info("[build-trace] step=scan_files_full BEGIN (delta files to scan=%d)", len(files_to_scan))
            scan_edges, scanned_paths = self._scan_files_full(
                files_to_scan, on_progress,
                index=index, current_paths=current_paths,
            )
            log.info("[build-trace] step=scan_files_full END (scanned=%d, edges_added=%d)",
                     len(scanned_paths), scan_edges)
            edges_created += scan_edges

            log.info(
                "Graph: incremental — %d added, %d modified, %d deleted, %d unchanged "
                "(wiki-link snapshot replayed for %d files)",
                len(delta.added), len(delta.modified), len(delta.deleted), len(delta.unchanged),
                len(self._wikilink_index),
            )
        else:
            log.info("[build-trace] step=full_rebuild_clear BEGIN")
            self._wikilink_graph.clear()
            self._spo_graph.clear()
            self._graph.clear()
            self._node_sources.clear()
            self._article_triples.clear()
            self._article_entities.clear()
            if self._wikilink_index:
                self._wikilink_index = {}
                self._wikilink_index_dirty = True
            log.info("[build-trace] step=full_rebuild_clear END")

            log.info("[build-trace] step=scan_files_full BEGIN (full rebuild — %d files)", len(files))
            edges_created, scanned_paths = self._scan_files_full(
                files, on_progress,
                index=index, current_paths=current_paths,
            )
            log.info("[build-trace] step=scan_files_full END (scanned=%d, edges=%d)",
                     len(scanned_paths), edges_created)

        log.info("[build-trace] step=overlay_structural_triples BEGIN")
        structural_added = self._overlay_structural_triples()
        log.info("[build-trace] step=overlay_structural_triples END (added=%d)", structural_added)
        if structural_added:
            log.info("Graph: emitted %d structural triples from project/task/goal files", structural_added)

        log.info("[build-trace] step=overlay_spo_edges BEGIN")
        spo_edges = self._overlay_spo_edges()
        log.info("[build-trace] step=overlay_spo_edges END (edges_added=%d)", spo_edges)
        edges_created += spo_edges

        log.info("[build-trace] step=cross_reference_triples BEGIN")
        xref_count = self._cross_reference_triples()
        log.info("[build-trace] step=cross_reference_triples END (xrefs=%d)", xref_count)

        log.info("[build-trace] step=merge_duplicate_nodes BEGIN")
        merged = self._merge_duplicate_nodes()
        log.info("[build-trace] step=merge_duplicate_nodes END (merged=%d)", merged)
        if merged:
            log.info("Graph merge pass: collapsed %d duplicate nodes", merged)

        log.info("[build-trace] step=persist_build_index BEGIN (scanned_paths=%d)", len(scanned_paths))
        if index is not None:
            for path in scanned_paths:
                stat = current_paths.get(path)
                if stat is not None:
                    index.record_processed(path, stat, FileContribution())
            try:
                index.save()
            except Exception:  # pragma: no cover — defensive, see save() docstring
                log.exception("BuildIndex save failed — non-fatal, next boot will rebuild")
        log.info("[build-trace] step=persist_build_index END")

        log.debug(
            "Knowledge graph built: %d wikilink nodes, %d spo nodes, %d combined edges, "
            "%d entities, %d triples, %d cross-refs",
            self._wikilink_graph.number_of_nodes(), self._spo_graph.number_of_nodes(),
            self._graph.number_of_edges(),
            len(self._registry.entities), len(self._triples), xref_count,
        )
        # Persist triples that were added this build (structural overlay etc.)
        # save() is a no-op if nothing is dirty.
        self.save()

        # Restore both fuzzy tiers to their prior state. Other callers
        # (resolve_detailed from chat / ingest, ambiguous-name handling)
        # rely on them being enabled for semantic matching on ambiguous
        # freeform input. The bulk-build disable is scoped to this
        # function only.
        self._registry._embedding_disabled = _prior_embed_disabled  # noqa: SLF001
        self._registry._fuzzy_disabled = _prior_fuzzy_disabled  # noqa: SLF001

        # ----- Phase 6: capture summary + log one-line outcome -----
        _build_completed_at_iso = _now_iso()
        _build_duration = time.monotonic() - _build_started_perf
        _mode = "incremental" if (use_incremental and delta is not None) else "full"

        if delta is not None:
            _delta_added = len(delta.added)
            _delta_modified = len(delta.modified)
            _delta_deleted = len(delta.deleted)
            _delta_unchanged = len(delta.unchanged)
            _files_processed = _delta_added + _delta_modified
        else:
            _delta_added = 0
            _delta_modified = 0
            _delta_deleted = 0
            _delta_unchanged = 0
            _files_processed = len(scanned_paths)

        self._last_build_summary = {
            "mode": _mode,
            "started_at": _build_started_at_iso,
            "completed_at": _build_completed_at_iso,
            "duration_seconds": round(_build_duration, 3),
            "files_added": _delta_added,
            "files_modified": _delta_modified,
            "files_deleted": _delta_deleted,
            "files_unchanged": _delta_unchanged,
            "files_processed": _files_processed,
            "edges_created": edges_created,
            "total_indexed_files": len(self._wikilink_index),
        }

        if _mode == "incremental":
            log.info(
                "Graph: incremental — %d added + %d modified + %d deleted = %d processed in %.2fs (%d unchanged); %d edges, %d total indexed",
                _delta_added, _delta_modified, _delta_deleted,
                _files_processed, _build_duration, _delta_unchanged,
                edges_created, len(self._wikilink_index),
            )
        else:
            log.info(
                "Graph: full rebuild — %d files in %.2fs, %d edges, %d entities, %d triples",
                _files_processed, _build_duration,
                edges_created, len(self._registry.entities), len(self._triples),
            )

        return edges_created

    def _resolve_index_path(self) -> Path | None:
        """Return the physical filesystem path for ``build_index.json``.

        Mirrors the resolution pattern used for ``triples.json`` at
        ``_load_graph_data`` (line ~590). Returns ``None`` when no vault
        is wired (test stubs / first-boot pre-init), in which case
        ``build()`` falls back to full-rebuild mode.
        """
        if not self._vault:
            return None
        if self._vault.paths is not None:
            return self._vault.paths.resolve(_BUILD_INDEX_PATH)
        # Bare-vault fallback (no VaultPaths instance — used by some tests)
        return self._vault.vault_dir / _BUILD_INDEX_PATH

    def _resolve_wikilink_index_path(self) -> Path | None:
        """Return the physical filesystem path for ``wikilink_index.json``.

        Used by build()'s watermark-seeding fallback (added 2026-04-25):
        when build_index.json is missing but wikilink_index.json exists,
        the snapshot's mtime serves as a "skip files older than this"
        hint that turns a kill-loop-orphaned vault into an incremental
        scan instead of a 5-minute full rebuild.
        """
        if not self._vault:
            return None
        if self._vault.paths is not None:
            return self._vault.paths.resolve(_WIKILINK_INDEX_PATH)
        return self._vault.vault_dir / _WIKILINK_INDEX_PATH

    def _stat_filtered_files(
        self,
        files: list[Path],
    ) -> tuple[dict[str, FileStat], dict[str, Path]]:
        """Stat every candidate file, filter by graph-scan rules.

        Returns ``(current_paths, files_by_path)``:

        - ``current_paths[relative] = FileStat(mtime_ns, size)``
        - ``files_by_path[relative] = absolute Path``

        Filter rules MUST stay byte-identical to the per-file gate inside
        ``_scan_files_full`` — adding a rule here without adding it there
        (or vice versa) causes the index to track files the scanner
        ignores, or vice versa, both of which break the diff contract.

        Stat failures (file vanished between list_files and stat) are
        silently dropped.
        """
        _SCAN_PREFIXES = (
            "knowledge/", "memory/", "system/identity/", "journal/",
            "system/agents/", "system/skills/", "projects/", "goals/",
        )
        _SKIP_PATTERNS = ("/library/", "/inbox/", "/.archives/", "/.history/")  # arch:deterministic — mirrors _scan_files_full
        _SKIP_BASENAMES = {"SCHEMA.md", "CONTRACT.md", "log.md", "_index.md", "_concepts.md"}

        current_paths: dict[str, FileStat] = {}
        files_by_path: dict[str, Path] = {}

        for file_path in files:
            if self._vault.paths:
                relative = self._vault.paths.to_logical(file_path)
            else:
                relative = str(file_path.relative_to(self._vault.vault_dir)).replace("\\", "/")

            if not any(relative.startswith(p) for p in _SCAN_PREFIXES):
                continue
            if any(s in relative for s in _SKIP_PATTERNS) or relative.endswith(".index.md"):
                continue
            basename = relative.rsplit("/", 1)[-1]
            if basename in _SKIP_BASENAMES:
                continue
            if "memory/summaries/" in relative and relative.count("/") > 2:
                continue

            try:
                st = file_path.stat()
            except OSError:
                continue
            current_paths[relative] = FileStat(mtime_ns=st.st_mtime_ns, size=st.st_size)
            files_by_path[relative] = file_path

        return current_paths, files_by_path

    def _retract_file_contributions(self, file_path: str) -> int:
        """Retract the triples a now-deleted file contributed.

        For every active triple where ``file_path`` is in ``source_files``:

        - Remove ``file_path`` from ``source_files``.
        - If ``source_files`` is now empty AND the triple's authority is
          ``"system"`` (file-derived, not user-confirmed) AND its
          predicate is NOT a structural one (``has_goal`` / ``works_on``
          / ``part_of`` / ``uses`` / ``assigned_to``): mark the triple
          ``status="retracted"``, ``epistemic_status="retracted"``,
          ``t_expired=now``.
        - If ``source_files`` is empty but authority is ``"explicit"``,
          leave the triple alone — the user said it once and Phase 5 is
          deliberately conservative about retracting user assertions
          (the file might come back via a git checkout / restore).

        **Composition with ``_overlay_structural_triples``** (Phase 5
        documentation): structural predicates are deliberately skipped
        here because the structural overlay runs its own retraction
        pass at the end of every ``build()``. The overlay walks the
        live project / goal / task / agent files; any structural triple
        whose source file did NOT get touched in the overlay pass is
        retracted there with full justification cascade. Letting this
        method also retract them would (a) double-write ``t_expired``
        and (b) potentially mis-order the cascade. The two paths
        compose by exclusion: this method handles non-structural
        predicates, the overlay handles structural ones.

        Phase 5 also downgrades the ``decay_condition`` of any registry
        entity that lost its only file source from ``"never"`` (file-
        backed) to ``"when:unaccessed:30d"`` so it gets swept later if
        unused. The conservative default (don't auto-purge) protects
        against losing accumulated aliases on git checkouts / file
        moves.

        Also removes the file_node from the wiki-link graph and combined
        graph (via ``_clear_file_state``), so a later
        ``_overlay_spo_edges`` pass doesn't accumulate ghost edges.

        Returns the number of triples retracted (excluding cascade —
        that runs in ``_overlay_structural_triples`` for structural
        predicates, and in Phase 5+ for non-structural).
        """
        structural_predicates = {"has_goal", "works_on", "part_of", "uses", "assigned_to"}
        now = _now_iso()
        retracted = 0

        for triple in self._triples:
            if triple.get("status") != "active":
                continue
            sources = triple.get("source_files") or []
            if file_path not in sources:
                continue
            new_sources = [s for s in sources if s != file_path]
            triple["source_files"] = new_sources
            self._triples_dirty = True

            if new_sources:
                # Other files still source this triple — leave it active.
                continue

            authority = triple.get("authority", "")
            predicate = triple.get("predicate", "")
            if authority == "explicit":
                # User said it once. Phase 5 will decide whether to downgrade.
                continue
            if predicate in structural_predicates:
                # _overlay_structural_triples handles this on its own pass.
                continue

            triple["status"] = "retracted"
            triple["epistemic_status"] = "retracted"
            triple["last_verified"] = now
            triple["t_expired"] = now  # Phase 7 bi-temporal
            retracted += 1

        # Phase 5: capture which entities this file contributed BEFORE we
        # clear them — _clear_file_state drops _article_entities[path] so
        # the slugs would be inaccessible after the call. We need them to
        # decide which registry entities just lost their only source.
        prior_slugs = set(self._article_entities.get(file_path, set()))

        # Phase 4: route graph + wikilink_index cleanup through the shared
        # helper. _clear_file_state drops the file_node from both graphs,
        # cleans the reverse index, drops _article_entities[path], and
        # drops _wikilink_index[path] (setting the dirty flag so the next
        # save() persists the updated snapshot). _article_triples cleanup
        # stays here — it's a deletion-specific concern that doesn't
        # apply to the modified-file path.
        self._clear_file_state(file_path)
        self._article_triples.pop(file_path, None)

        # Phase 5: entities that lost their only file source get their
        # decay_condition downgraded from "never" (file-backed, immortal)
        # to "when:unaccessed:30d" (sweeps if unused). Conservative — we
        # don't auto-purge because the user might have accumulated aliases
        # on this entity that would be lost on git checkouts / file moves.
        # If the file comes back, the next scan re-registers the entity
        # with decay_condition="never" and the downgrade is reversed.
        for slug in prior_slugs:
            canonical = self._registry.get_name(slug)
            if not canonical:
                continue
            # Still has other file sources? Leave it alone.
            if canonical in self._node_sources and self._node_sources[canonical]:
                continue
            entity = self._registry.entities.get(slug)
            if entity and entity.get("decay_condition") == "never":
                entity["decay_condition"] = "when:unaccessed:30d"
                self._registry._dirty = True  # noqa: SLF001 — intentional internal mutation

        return retracted

    def _add_file_to_graphs(
        self,
        relative: str,
        label: str,
        entity_type: str,
        raw_links: list[str],
    ) -> tuple[int, set[str]]:
        """Add a file's nodes + edges to wiki-link + combined graphs.

        Phase 4 — pure projection function shared between two callers:

        - ``_scan_files_full`` calls it after reading a file from disk and
          parsing wikilinks.
        - ``_project_wikilink_index`` calls it during incremental boot to
          replay the cached snapshot WITHOUT re-reading the file.

        ``raw_links`` is the list of contents-inside-``[[...]]`` strings
        BEFORE registry resolution. Resolution happens here against the
        current registry, so newly-registered entities show up on the
        next call even for cached entries.

        Returns ``(edges_created, article_slugs)``. ``article_slugs`` is
        the set of registry slugs successfully resolved from the link
        text — used by the caller to populate ``_article_entities``.
        """
        edges_created = 0
        file_node = relative

        # No-links path: file becomes a node only when it represents a
        # typed entity (project / goal / agent / skill / etc).
        if not raw_links:
            if entity_type:
                self._wikilink_graph.add_node(file_node, type="file", label=label, entity_type=entity_type)
                self._graph.add_node(file_node, type="file", label=label, entity_type=entity_type)
                if entity_type not in ("source_summary", "synthesis", "decision_record"):
                    display_name = label
                    if not self._graph.has_node(display_name):
                        self._graph.add_node(display_name, type="entity", label=display_name, entity_type=entity_type)
                    elif not self._graph.nodes[display_name].get("entity_type"):
                        self._graph.nodes[display_name]["entity_type"] = entity_type
            return edges_created, set()

        # Has wikilinks — file becomes a node, plus its display-name entity
        # if the file is itself a typed entity page.
        self._wikilink_graph.add_node(file_node, type="file", label=label, entity_type=entity_type)
        self._graph.add_node(file_node, type="file", label=label, entity_type=entity_type)

        if entity_type and entity_type not in ("source_summary", "synthesis", "decision_record"):
            display_name = label
            if not self._graph.has_node(display_name):
                self._graph.add_node(display_name, type="entity", label=display_name, entity_type=entity_type)
            elif not self._graph.nodes[display_name].get("entity_type"):
                self._graph.nodes[display_name]["entity_type"] = entity_type

        article_slugs: set[str] = set()
        blocklist = _load_memory_policy()["link_blocklist"]

        for link in raw_links:
            link = link.strip()
            if not link or link.lower() in blocklist:
                continue
            slug = self._registry.resolve(link)
            if not slug:
                # Unresolved wikilink — skip phantom node creation. Phase 4
                # re-runs this projection on every load, so a future
                # registry update will pick it up without re-reading the
                # source file.
                continue
            canonical = self._registry.get_name(slug) or link
            node_id = canonical
            article_slugs.add(slug)
            node_etype = self._registry.entities.get(slug, {}).get("entity_type", "")

            if not self._wikilink_graph.has_node(node_id):
                self._wikilink_graph.add_node(node_id, type="entity", label=node_id, entity_type=node_etype)
            if not self._graph.has_node(node_id):
                self._graph.add_node(node_id, type="entity", label=node_id, entity_type=node_etype)
            elif node_etype and not self._graph.nodes[node_id].get("entity_type"):
                self._graph.nodes[node_id]["entity_type"] = node_etype

            if node_id not in self._node_sources:
                self._node_sources[node_id] = set()
            self._node_sources[node_id].add(file_node)

            if not self._wikilink_graph.has_edge(file_node, node_id):
                self._wikilink_graph.add_edge(file_node, node_id, relation="mentions")
                self._graph.add_edge(file_node, node_id, relation="mentions")
                edges_created += 1

        # Co-mention edges (wiki-link graph only, NOT in SPO). Skip on
        # index files (catalogs that mention dozens of unrelated things)
        # and on files with too many links (60+ links → 1800+ pairs).
        basename = relative.rsplit("/", 1)[-1].lower()
        is_index = basename in (".index.md", ".concepts.md", "_catalog.md") or "/.archives/" in relative
        if not is_index and len(raw_links) <= 50:
            for i, link_a in enumerate(raw_links):
                for link_b in raw_links[i + 1:]:
                    a, b = link_a.strip(), link_b.strip()
                    if a and b and a != b and a.lower() not in blocklist and b.lower() not in blocklist:
                        if not self._wikilink_graph.has_edge(a, b):
                            self._wikilink_graph.add_edge(a, b, relation="co-mentioned")
                            edges_created += 1
                        if not self._graph.has_edge(a, b):
                            self._graph.add_edge(a, b, relation="co-mentioned")

        # Article-level entity index — used by _cross_reference_triples to
        # link triples to wiki articles that mention their entities.
        if article_slugs and "/wiki/" in relative:
            self._article_entities[relative] = article_slugs

        return edges_created, article_slugs

    def _clear_file_state(self, relative: str) -> None:
        """Remove a file's graph-state contributions before re-scan / deletion.

        Phase 4 — idempotent helper used by:

        - ``_scan_files_full`` at the top of every per-file body. In
          full-rebuild mode this is a no-op (graph was just cleared); in
          incremental mode it cleans up stale state for modified files
          before re-projection adds the new state.
        - ``_retract_file_contributions`` for deleted files (then the
          caller goes further and retracts the file's triples).

        Does NOT touch triples. Triple retraction is a separate concern
        owned by ``_retract_file_contributions``.
        """
        prior_slugs = set(self._article_entities.get(relative, set()))

        if self._wikilink_graph.has_node(relative):
            self._wikilink_graph.remove_node(relative)
        if self._graph.has_node(relative):
            self._graph.remove_node(relative)

        # Clean the reverse index: this file no longer sources any entity.
        for slug in prior_slugs:
            canonical = self._registry.get_name(slug)
            if canonical and canonical in self._node_sources:
                self._node_sources[canonical].discard(relative)
                if not self._node_sources[canonical]:
                    del self._node_sources[canonical]

        self._article_entities.pop(relative, None)
        if relative in self._wikilink_index:
            del self._wikilink_index[relative]
            self._wikilink_index_dirty = True

    def _project_wikilink_index(self) -> int:
        """Replay the cached wiki-link snapshot onto the in-memory graphs.

        Phase 4 — called by the incremental branch of ``build()`` to
        populate file_nodes / entity_nodes / edges for every cached file
        WITHOUT re-reading from disk. Re-resolves raw link text against
        the current registry on every call, so newly-registered entities
        get picked up automatically.

        Returns the number of edges added.

        Performance guard (added 2026-04-25):
          Resolving every wikilink in every cached file means O(files ×
          links_per_file) calls to ``registry.resolve``. On a 2600-file
          / 7000-entity vault that's ~132K calls, and Tier 4
          SequenceMatcher fallback iterates ALL entities × name_length²
          per call — billions of operations, CPU pinned, no progress.
          Disable fuzzy matching for the duration of the projection:
          Tiers 1+2 (exact slug, alias index) are O(1) and handle every
          link that was successfully resolved in the original scan,
          which is all the projection cares about. New unresolved links
          will be picked up by the per-file scan that follows.
        """
        prior_fuzzy_disabled = self._registry._fuzzy_disabled  # noqa: SLF001
        prior_embed_disabled = self._registry._embedding_disabled  # noqa: SLF001
        self._registry._fuzzy_disabled = True  # noqa: SLF001
        self._registry._embedding_disabled = True  # noqa: SLF001
        try:
            edges_created = 0
            n_files = len(self._wikilink_index)
            log_every = max(500, n_files // 5)  # 5 progress checkpoints minimum
            processed = 0
            for relative, entry in self._wikilink_index.items():
                processed += 1
                if processed % log_every == 0:
                    log.info("[build-trace] _project_wikilink_index: %d/%d files projected (edges=%d)",
                             processed, n_files, edges_created)
                if not isinstance(entry, dict):
                    continue
                label = entry.get("label") or relative.rsplit("/", 1)[-1]
                entity_type = entry.get("entity_type", "") or ""
                raw_links = entry.get("wiki_links") or []
                if not isinstance(raw_links, list):
                    continue
                edges, _slugs = self._add_file_to_graphs(
                    relative, label, entity_type, [str(link) for link in raw_links],
                )
                edges_created += edges
            return edges_created
        finally:
            self._registry._fuzzy_disabled = prior_fuzzy_disabled  # noqa: SLF001
            self._registry._embedding_disabled = prior_embed_disabled  # noqa: SLF001

    def _load_wikilink_index(self) -> None:
        """Load the wiki-link snapshot from disk into ``self._wikilink_index``.

        Fail-soft on every error path (missing file, corrupt JSON, schema
        mismatch, root-not-dict, files-not-dict). The build() caller checks
        ``len(self._wikilink_index)`` to decide whether to project.
        """
        if not self._vault:
            return
        try:
            if self._vault.paths is not None:
                path = self._vault.paths.resolve(_WIKILINK_INDEX_PATH)
            else:
                path = self._vault.vault_dir / _WIKILINK_INDEX_PATH
        except Exception:  # pragma: no cover — defensive
            return
        if not path.is_file():
            return
        try:
            raw = path.read_text(encoding="utf-8")
        except OSError:
            log.warning("WikilinkIndex: cannot read %s", path)
            return
        if not raw.strip():
            return
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError as exc:
            log.warning("WikilinkIndex: %s is corrupted (%s) — discarding", path, exc)
            return
        if not isinstance(payload, dict):
            return
        if payload.get("schema_version") != _WIKILINK_INDEX_SCHEMA_VERSION:
            log.info(
                "WikilinkIndex: schema_version mismatch (stored=%s, expected=%s) — discarding",
                payload.get("schema_version"), _WIKILINK_INDEX_SCHEMA_VERSION,
            )
            return
        files = payload.get("files")
        if not isinstance(files, dict):
            return
        self._wikilink_index = files
        # Loading from disk is NOT a mutation that should re-write the
        # file. Only scan / retract operations set the dirty flag.
        log.debug("WikilinkIndex: loaded %d entries", len(files))

    def _save_wikilink_index(self) -> None:
        """Atomically persist the wiki-link snapshot if it was mutated.

        No-op when ``_wikilink_index_dirty`` is False. Atomic ``.tmp`` +
        ``Path.replace()`` pattern, ``sort_keys=True`` for git-diff
        readability.
        """
        if not self._vault or not self._wikilink_index_dirty:
            return
        try:
            if self._vault.paths is not None:
                path = self._vault.paths.resolve(_WIKILINK_INDEX_PATH)
            else:
                path = self._vault.vault_dir / _WIKILINK_INDEX_PATH
        except Exception:  # pragma: no cover — defensive
            return

        payload = {
            "schema_version": _WIKILINK_INDEX_SCHEMA_VERSION,
            "files": self._wikilink_index,
        }

        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".json.tmp")
        try:
            tmp.write_text(
                json.dumps(payload, indent=2, sort_keys=True),
                encoding="utf-8",
            )
            tmp.replace(path)
        except OSError:
            log.exception("WikilinkIndex: failed to write %s", path)
            if tmp.exists():
                try:
                    tmp.unlink()
                except OSError:
                    pass
            return
        self._wikilink_index_dirty = False

    def _scan_files_full(
        self,
        files: list[Path],
        on_progress: Callable[[int, int], None] | None = None,
        *,
        index: BuildIndex | None = None,
        current_paths: dict[str, FileStat] | None = None,
        checkpoint_every: int = 200,
    ) -> tuple[int, set[str]]:
        """Walk filtered markdown files, populate wikilink + combined graph.

        Pure file-scan body extracted from build() (Phase 1 of the incremental-
        build refactor — see tasks/plan-incremental-graph-build.md). No
        behavior change vs. the inlined version: same filters, same node /
        edge creation, same progress callback semantics.

        Returns (edges_created, scanned_paths). scanned_paths is the set of
        relative paths that survived all filters AND read successfully.

        Checkpointing (added 2026-04-25):
          When ``index`` and ``current_paths`` are passed, every successfully
          scanned file is recorded into ``index`` immediately, AND the index
          is flushed to disk every ``checkpoint_every`` files. This means an
          interrupted build (process killed, exception, asyncio.CancelledError)
          leaves a USABLE partial index on disk — next boot loads it, diffs
          against the vault, and only re-processes the files that weren't in
          the checkpoint. The historical bug was that ``index.save()`` only
          ran at the END of a successful build; every interrupted build wrote
          nothing, so the next boot did a fresh full rebuild from scratch.
          This made the architecturally-correct fast path (``Obsidian``-style
          load-and-diff) unreachable in practice. Checkpoint cost is one
          ``json.dumps`` + atomic write per 200 files — milliseconds —
          versus minutes of re-scanning on the next boot if it's missed.
        """
        from agntspace.memory.build_index import (
            FileContribution as _FC,  # local import — avoid module-level cycle
        )
        _SCAN_PREFIXES = (
            "knowledge/", "memory/", "system/identity/", "journal/",
            "system/agents/", "system/skills/", "projects/", "goals/",
        )
        _SKIP_PATTERNS = ("/library/", "/inbox/", "/.archives/", "/.history/")  # arch:deterministic — structural internals of KB + version history; never participate in the knowledge graph

        total_files = len(files)
        scanned = 0
        edges_created = 0
        scanned_paths: set[str] = set()

        for file_path in files:
            scanned += 1
            if on_progress and (scanned % 1000 == 0 or scanned == total_files):
                try:
                    on_progress(scanned, total_files)
                except Exception:
                    pass  # broken callback must never break startup

            if self._vault.paths:
                relative = self._vault.paths.to_logical(file_path)
            else:
                relative = str(file_path.relative_to(self._vault.vault_dir)).replace("\\", "/")

            # Only scan wiki articles, memory, identity — not library copies of user files
            if not any(relative.startswith(p) for p in _SCAN_PREFIXES):
                continue
            if any(s in relative for s in _SKIP_PATTERNS) or relative.endswith(".index.md"):
                continue
            # Skip KB meta/config files — they contain syntax examples, not real content
            basename = relative.rsplit("/", 1)[-1]
            if basename in ("SCHEMA.md", "CONTRACT.md", "log.md", "_index.md", "_concepts.md"):
                continue
            # Skip memory summary archives (summaries/{layer}/{date}.md) — only scan current summaries
            if "memory/summaries/" in relative and relative.count("/") > 2:
                continue

            try:
                doc = self._vault.read(relative)
            except Exception:
                continue

            scanned_paths.add(relative)

            # Checkpoint (added 2026-04-25): record this file in the
            # build index AS we go, and flush every checkpoint_every files.
            # Without this, the only persistence happened at the very end
            # of build() — every interrupted build wrote nothing, so the
            # next boot did a full rebuild from scratch. With it, an
            # interrupted build at file 1500/4396 still leaves 1500
            # entries on disk, and the next boot scans only the remaining
            # ~2900. This is the architecturally-correct fast path that
            # was in the design but unreachable in practice.
            if index is not None and current_paths is not None:
                stat = current_paths.get(relative)
                if stat is not None:
                    try:
                        index.record_processed(relative, stat, _FC())
                    except Exception:
                        pass  # bookkeeping should never break a build
                if checkpoint_every > 0 and (scanned % checkpoint_every == 0):
                    try:
                        index.save()
                    except Exception:
                        # Disk pressure / cloud-sync rename race: skip this
                        # checkpoint, the next one (or end-of-build) will
                        # capture the full state. Better than crashing the
                        # whole build because of a transient FS issue.
                        log.debug("BuildIndex checkpoint failed at %d files (non-fatal)", scanned, exc_info=True)

            # Phase 4: clear stale graph state for this file before re-projecting.
            # In full-rebuild mode this is a no-op (graphs were just cleared by
            # build()); in incremental mode it cleans up old edges from
            # modified files so we don't accumulate ghost references.
            self._clear_file_state(relative)

            raw_links = [link.strip() for link in WIKI_LINK_PATTERN.findall(doc.content)]
            file_etype = doc.metadata.get("entity_type", "") or _etype_from_path(relative)
            file_label = doc.metadata.get("title", file_path.stem)

            # Phase 5: keep the entity registry incrementally in sync with
            # frontmatter edits. Without this call, modified-file entity_type
            # / title / aliases changes would only land in the registry on
            # the next full rebuild. Idempotent — _registry.add merges
            # aliases and respects authority hierarchy. Registry persistence
            # happens in save() below via self._registry.save().
            self._register_file_as_entity(relative, file_path.stem, doc.metadata)

            # Phase 4: cache the per-file snapshot so future incremental boots
            # can re-project against the current registry without re-reading
            # the file. RAW link text (not resolved slugs) so renames + new
            # registry entries are picked up automatically on next load.
            self._wikilink_index[relative] = {
                "label": file_label,
                "entity_type": file_etype,
                "wiki_links": raw_links,
            }
            self._wikilink_index_dirty = True

            edges, _slugs = self._add_file_to_graphs(
                relative, file_label, file_etype, raw_links,
            )
            edges_created += edges

        return edges_created, scanned_paths

    def rebuild_registry_from_files(self) -> int:
        """Populate the entity registry from scanned file frontmatter.

        Walks the same directories as build(), reads each markdown file's
        frontmatter, and registers every file that represents an entity
        (has entity_type in frontmatter OR lives under a path-typed dir like
        system/agents/ or system/skills/).

        Each file contributes: canonical_name (title), entity_type, aliases.
        Authority = "system" (agent-observed from file), never downgrades if
        the entity already exists with higher authority.

        Returns the number of entities added or updated.
        """
        if not self._vault:
            return 0

        # Phase 1 of the incremental-build refactor: the file-walk loop body
        # was extracted into _scan_for_registry() so future phases can pass a
        # delta (added/modified files) instead of the full file list.
        files = self._vault.list_files(pattern="**/*.md")
        count = self._scan_for_registry(files)

        if count:
            self._registry.save()

        return count

    def _register_file_as_entity(
        self,
        relative: str,
        file_stem: str,
        metadata: dict,
    ) -> str:
        """Register one file as a registry entity if its frontmatter implies one.

        Phase 5 — pure helper extracted from ``_scan_for_registry`` so the
        same per-file logic can be called from ``_scan_files_full`` after a
        modified file is re-read. This keeps the registry incrementally in
        sync with frontmatter edits (new entity_type, new aliases) without
        waiting for a full ``rebuild_registry_from_files()`` call.

        Returns the slug if the file produced a registry entry, otherwise
        ``""``. Caller batches ``self._registry.save()`` after.

        Files whose ``entity_type`` is missing or matches a structural type
        that should NOT be a first-class entity (``source_summary``,
        ``synthesis``, ``decision_record``) are skipped — they participate
        in the graph as file_nodes but not as registry entries.
        """
        etype = metadata.get("entity_type", "") or _etype_from_path(relative)
        if not etype or etype in ("source_summary", "synthesis", "decision_record"):
            return ""

        # Entity name: prefer frontmatter title, then name field, then filename stem
        name = (
            metadata.get("title")
            or metadata.get("name")
            or file_stem
        )
        if not name:
            return ""

        # Aliases: frontmatter aliases + filename stem (if different from title)
        aliases: list[str] = []
        fm_aliases = metadata.get("aliases")
        if isinstance(fm_aliases, list):
            aliases.extend(str(a) for a in fm_aliases if a)
        elif isinstance(fm_aliases, str) and fm_aliases.strip():
            aliases.append(fm_aliases.strip())
        if file_stem and file_stem != name:
            aliases.append(file_stem)

        slug = self._registry.add(
            name=name,
            entity_type=etype,
            authority="system",
            aliases=aliases or None,
            decay_condition="never",  # file-backed entities don't decay
        )
        return slug or ""

    def _scan_for_registry(self, files: list[Path]) -> int:
        """Walk markdown files, register every entity-bearing one.

        Pure file-scan body extracted from rebuild_registry_from_files() (Phase
        1 of the incremental-build refactor — see
        tasks/plan-incremental-graph-build.md). Phase 5 further extracted the
        per-file registration into ``_register_file_as_entity`` so the same
        helper can be called from ``_scan_files_full`` for incremental
        registry updates. Caller is responsible for calling
        ``self._registry.save()`` after this if the returned count > 0.

        Returns the number of entities added or updated.
        """
        _SCAN_PREFIXES = (
            "knowledge/", "memory/", "system/identity/", "journal/",
            "system/agents/", "system/skills/", "projects/", "goals/",
        )
        _SKIP_PATTERNS = ("/library/", "/inbox/", "/.archives/", "/.history/")  # arch:deterministic — mirrors the scanner above; KB internals that never become graph nodes
        _SKIP_BASENAMES = {"SCHEMA.md", "CONTRACT.md", "log.md", "_index.md", "_concepts.md"}

        count = 0

        for file_path in files:
            if self._vault.paths:
                relative = self._vault.paths.to_logical(file_path)
            else:
                relative = str(file_path.relative_to(self._vault.vault_dir)).replace("\\", "/")

            if not any(relative.startswith(p) for p in _SCAN_PREFIXES):
                continue
            if any(s in relative for s in _SKIP_PATTERNS):
                continue
            basename = relative.rsplit("/", 1)[-1]
            if basename in _SKIP_BASENAMES:
                continue
            # Skip SKILL.md file-structure: every skill dir has a SKILL.md,
            # that IS the skill — don't skip it.

            try:
                doc = self._vault.read(relative)
            except Exception:
                continue

            slug = self._register_file_as_entity(relative, file_path.stem, doc.metadata)
            if slug:
                count += 1

        return count

    def refresh_structural_triples(self) -> int:
        """Re-run the structural overlay and project new edges onto the graph.

        Call this after a project/task/goal file is created or updated so
        the new relationship shows up in the graph without a full rebuild.
        Deduped by (subject, predicate, object), so re-running is safe.
        Returns the number of triples added or refreshed.
        """
        added = self._overlay_structural_triples()
        if added:
            self._overlay_spo_edges()
            self.save()
        return added

    def _overlay_structural_triples(self) -> int:
        """Extract SPO triples from project/task/goal frontmatter.

        Projects reference their related goals + assigned agents, and tasks
        reference their parent project — all as frontmatter fields that code
        manipulates but the graph never saw. This method walks those files
        once, resolves slug references to canonical titles, and emits:

        - (project_title, ``has_goal``, goal_title)   -- from related_goals
        - (agent_title, ``works_on``, project_title)  -- from assigned_agents
        - (task_title, ``part_of``, project_title)    -- from project field

        Triples are deduped against existing ones by (subject, predicate,
        object), so rerunning build() doesn't create duplicates. Authority is
        ``system`` (file-derived, not user-confirmed).

        Returns the number of triples added or refreshed.
        """
        if not self._vault:
            return 0

        # Phase 1 of the incremental-build refactor: the file-walk that
        # populates the structural lookup dicts was extracted into
        # _collect_structural_records() so future phases can pass a delta
        # (added/modified files) instead of the full file list.
        files = self._vault.list_files(pattern="**/*.md")
        (
            project_titles,
            goal_titles,
            agent_titles,
            project_records,
            task_records,
        ) = self._collect_structural_records(files)

        def _coerce_list(value) -> list[str]:
            if value is None:
                return []
            if isinstance(value, list):
                return [str(v).strip() for v in value if v is not None and str(v).strip()]
            if isinstance(value, str):
                stripped = value.strip()
                if not stripped:
                    return []
                return [stripped]
            return [str(value)]

        added = 0
        touched_ids: set[int] = set()

        for project_title, meta, rel in project_records:
            # project has_goal goal — only if the goal actually exists
            for goal_ref in _coerce_list(meta.get("related_goals")):
                if goal_ref not in goal_titles:
                    log.debug("Structural: skipping has_goal — %s not found in goals", goal_ref)
                    continue
                resolved = goal_titles[goal_ref]
                tid = self.add_triple(
                    project_title, "has_goal", resolved,
                    authority="system", source_file=rel,
                    subject_type="project", object_type="goal",
                    knowledge_type="intentional",
                    confidence=0.95,
                    decay_condition="never",
                    epistemic_status="verified",
                )
                touched_ids.add(tid)
                added += 1

            # agent works_on project — only if the agent actually exists
            for agent_ref in _coerce_list(meta.get("assigned_agents")):
                if agent_ref not in agent_titles:
                    log.debug("Structural: skipping works_on — %s not found in agents", agent_ref)
                    continue
                resolved = agent_titles[agent_ref]
                tid = self.add_triple(
                    resolved, "works_on", project_title,
                    authority="system", source_file=rel,
                    subject_type="ai_agent", object_type="project",
                    knowledge_type="declarative",
                    confidence=0.95,
                    decay_condition="never",
                    epistemic_status="verified",
                )
                touched_ids.add(tid)
                added += 1

            # project uses kb  (linked_kbs → knowledge boundaries as concepts)
            for kb_ref in _coerce_list(meta.get("linked_kbs")):
                kb_label = str(kb_ref).strip()
                if not kb_label:
                    continue
                # KBs aren't formal entities — treat them as concept nodes
                # labeled by their slug. Humans see "KB: general" style titles
                # in the wiki; here we use the raw slug so the edge resolves.
                tid = self.add_triple(
                    project_title, "uses", kb_label,
                    authority="system", source_file=rel,
                    subject_type="project", object_type="concept",
                    knowledge_type="declarative",
                    confidence=0.95,
                    decay_condition="never",
                    epistemic_status="verified",
                )
                touched_ids.add(tid)
                added += 1

        for task_title, meta, rel in task_records:
            project_ref = meta.get("project")
            if project_ref and str(project_ref) in project_titles:
                resolved_project = project_titles[str(project_ref)]
                tid = self.add_triple(
                    task_title, "part_of", resolved_project,
                    authority="system", source_file=rel,
                    subject_type="task", object_type="project",
                    knowledge_type="declarative",
                    confidence=0.95,
                    decay_condition="never",
                    epistemic_status="verified",
                )
                touched_ids.add(tid)
                added += 1

            # task assigned_to agent — only if the agent actually exists
            agent_ref = meta.get("assigned_agent")
            if agent_ref:
                agent_ref_str = str(agent_ref).strip()
                if agent_ref_str and agent_ref_str in agent_titles:
                    resolved_agent = agent_titles[agent_ref_str]
                    tid = self.add_triple(
                        task_title, "assigned_to", resolved_agent,
                        authority="system", source_file=rel,
                        subject_type="task", object_type="ai_agent",
                        knowledge_type="declarative",
                        confidence=0.95,
                        decay_condition="never",
                        epistemic_status="verified",
                    )
                    touched_ids.add(tid)
                    added += 1

        # Retract structural triples whose source file was deleted or whose
        # frontmatter link was removed. A triple is "structural" if its source
        # file sits under projects/ or goals/ AND its predicate is one of the
        # structural relations. If this pass didn't touch it, the underlying
        # relationship no longer exists — mark retracted and cascade along
        # justification chains (so inferred inverses like `has_part` also go).
        structural_prefixes = ("projects/", "goals/")
        structural_predicates = {"has_goal", "works_on", "part_of", "uses", "assigned_to"}
        now = _now_iso()
        to_retract_ids: list[int] = []
        for triple in self._triples:
            if triple.get("status") != "active":
                continue
            if triple.get("predicate") not in structural_predicates:
                continue
            sources = triple.get("source_files") or []
            if not any(s.startswith(p) for s in sources for p in structural_prefixes):
                continue
            if triple.get("id") in touched_ids:
                continue
            triple["status"] = "retracted"
            triple["epistemic_status"] = "retracted"
            triple["last_verified"] = now
            triple["t_expired"] = now  # Phase 7 bi-temporal
            self._triples_dirty = True
            to_retract_ids.append(triple["id"])

        # Cascade: inferred triples that depended on a retracted one lose
        # their premise entirely — not just downgrade to uncertain, since
        # the source file is definitively gone. Walk justification chains
        # iteratively until no new retractions happen (handles inverse
        # inferences and chains from inverses).
        if to_retract_ids:
            retracted_set = set(to_retract_ids)
            changed = True
            while changed:
                changed = False
                for triple in self._triples:
                    if triple.get("status") != "active":
                        continue
                    justifiers = triple.get("justified_by") or []
                    if not justifiers:
                        continue
                    if any(j in retracted_set for j in justifiers):
                        triple["status"] = "retracted"
                        triple["epistemic_status"] = "retracted"
                        triple["last_verified"] = now
                        triple["t_expired"] = now  # Phase 7 bi-temporal
                        retracted_set.add(triple["id"])
                        self._triples_dirty = True
                        changed = True

        return added

    def _collect_structural_records(
        self,
        files: list[Path],
    ) -> tuple[dict[str, str], dict[str, str], dict[str, str], list[tuple[str, dict, str]], list[tuple[str, dict, str]]]:
        """Walk markdown files, extract project/goal/task/agent metadata.

        Pure file-scan body extracted from _overlay_structural_triples()
        (Phase 1 of the incremental-build refactor — see
        tasks/plan-incremental-graph-build.md). No behavior change vs. the
        inlined version: same path-based etype inference, same per-type slug
        / title resolution, same skip-on-read-failure semantics.

        Returns ``(project_titles, goal_titles, agent_titles, project_records,
        task_records)``:

        - ``project_titles[slug] -> title``
        - ``goal_titles[slug] -> title``
        - ``agent_titles[agent_key] -> title``
        - ``project_records: [(title, metadata, relative_path), ...]``
        - ``task_records: [(title, metadata, relative_path), ...]``
        """
        project_titles: dict[str, str] = {}
        goal_titles: dict[str, str] = {}
        agent_titles: dict[str, str] = {}
        project_records: list[tuple[str, dict, str]] = []
        task_records: list[tuple[str, dict, str]] = []

        for file_path in files:
            if self._vault.paths:
                relative = self._vault.paths.to_logical(file_path)
            else:
                relative = str(file_path.relative_to(self._vault.vault_dir)).replace("\\", "/")

            etype = _etype_from_path(relative)

            if etype == "project":
                parts = relative.split("/")
                if len(parts) < 3 or parts[0] != "projects":
                    continue
                slug = parts[1]
                try:
                    doc = self._vault.read(relative)
                except Exception:
                    continue
                title = doc.metadata.get("title") or slug
                project_titles[slug] = title
                project_records.append((title, doc.metadata, relative))

            elif etype == "goal":
                parts = relative.split("/")
                if len(parts) != 2 or parts[0] != "goals":
                    continue
                slug = parts[1].removesuffix(".md")
                try:
                    doc = self._vault.read(relative)
                except Exception:
                    continue
                title = doc.metadata.get("title") or slug
                goal_titles[slug] = title

            elif etype == "task":
                try:
                    doc = self._vault.read(relative)
                except Exception:
                    continue
                title = doc.metadata.get("title") or file_path.stem
                task_records.append((title, doc.metadata, relative))

            elif etype == "ai_agent":
                try:
                    doc = self._vault.read(relative)
                except Exception:
                    continue
                agent_key = file_path.stem
                agent_title = doc.metadata.get("title") or doc.metadata.get("name") or agent_key
                agent_titles[agent_key] = agent_title

        return project_titles, goal_titles, agent_titles, project_records, task_records

    def _merge_duplicate_nodes(self) -> int:
        """Collapse duplicate graph nodes that represent the same entity.

        Two duplication patterns:
        1. File node + display-name entity node for the same markdown page
           (e.g. "system/agents/researcher.md" + "Researcher").
        2. Case/spacing variants of the same label
           (e.g. "Researcher" + "researcher" + "researcher-1").

        Strategy:
        - Group nodes by slugified label.
        - Winner priority: type=file > type=entity with entity_type > type=entity.
        - Redirect all edges from losers to the winner, then drop losers.

        Returns the number of nodes merged (losers removed).
        """
        merged = 0

        # Group by slug(label). File nodes are keyed by their filename stem so
        # they cluster with wikilink nodes that reference them.
        groups: dict[str, list[str]] = {}
        for node in list(self._graph.nodes):
            data = self._graph.nodes[node]
            ntype = data.get("type", "")
            label = data.get("label", node)
            if ntype == "file":
                # Use the display label (from frontmatter title) for grouping
                key = _slugify(label)
            else:
                key = _slugify(str(node))
            if not key:
                continue
            groups.setdefault(key, []).append(node)

        def _rank(node_id: str) -> tuple[int, int, int]:
            """Sort key: higher is better. (type_rank, has_etype, degree)"""
            data = self._graph.nodes[node_id]
            ntype = data.get("type", "")
            type_rank = 2 if ntype == "file" else 1
            has_etype = 1 if data.get("entity_type") else 0
            return (type_rank, has_etype, self._graph.degree(node_id))

        for key, node_ids in groups.items():
            if len(node_ids) < 2:
                continue

            # Pick winner (highest rank)
            node_ids.sort(key=_rank, reverse=True)
            winner = node_ids[0]
            losers = node_ids[1:]

            winner_data = self._graph.nodes[winner]

            # Fill in winner's entity_type from losers if empty
            if not winner_data.get("entity_type"):
                for loser in losers:
                    loser_etype = self._graph.nodes[loser].get("entity_type")
                    if loser_etype:
                        winner_data["entity_type"] = loser_etype
                        break

            for loser in losers:
                # Redirect edges: for every (loser, neighbor), add (winner, neighbor)
                for neighbor in list(self._graph.neighbors(loser)):
                    if neighbor == winner:
                        continue
                    edge_data = self._graph.get_edge_data(loser, neighbor) or {}
                    if self._graph.has_edge(winner, neighbor):
                        existing = self._graph.edges[winner, neighbor]
                        # Merge relations list if present
                        if "relations" in edge_data or "relations" in existing:
                            combined = set(existing.get("relations", []))
                            combined.update(edge_data.get("relations", []))
                            if edge_data.get("relation"):
                                combined.add(edge_data["relation"])
                            if existing.get("relation"):
                                combined.add(existing["relation"])
                            existing["relations"] = sorted(combined)
                        existing["weight"] = existing.get("weight", 1.0) + edge_data.get("weight", 1.0)
                    else:
                        self._graph.add_edge(winner, neighbor, **edge_data)

                # Move node_sources entries
                if loser in self._node_sources:
                    self._node_sources.setdefault(winner, set()).update(self._node_sources[loser])
                    del self._node_sources[loser]

                self._graph.remove_node(loser)

                # Same for wikilink graph
                if self._wikilink_graph.has_node(loser):
                    for neighbor in list(self._wikilink_graph.neighbors(loser)):
                        if neighbor == winner:
                            continue
                        edge_data = self._wikilink_graph.get_edge_data(loser, neighbor) or {}
                        if not self._wikilink_graph.has_node(winner):
                            self._wikilink_graph.add_node(
                                winner, **self._graph.nodes[winner],
                            )
                        if not self._wikilink_graph.has_edge(winner, neighbor):
                            self._wikilink_graph.add_edge(winner, neighbor, **edge_data)
                    self._wikilink_graph.remove_node(loser)

                merged += 1

        return merged

    def _overlay_spo_edges(self) -> int:
        """Add edges from SPO triples onto both SPO graph and combined graph."""
        edges_created = 0
        for t in self._triples:
            if t["status"] != "active" or not t.get("object"):
                continue

            subj_name = self._registry.get_name(t["subject"])
            obj_name = self._registry.get_name(t.get("object"))
            if not subj_name or subj_name == "?" or not obj_name or obj_name == "?":
                continue

            weight = AUTHORITY_WEIGHTS.get(t.get("authority", "inferred"), 1.0) * t.get("confidence", 0.8)

            # SPO graph (directed)
            if not self._spo_graph.has_node(subj_name):
                self._spo_graph.add_node(subj_name, type="entity", label=subj_name)
            if not self._spo_graph.has_node(obj_name):
                self._spo_graph.add_node(obj_name, type="entity", label=obj_name)

            if self._spo_graph.has_edge(subj_name, obj_name):
                existing = self._spo_graph[subj_name][obj_name]
                existing["weight"] = existing.get("weight", 1.0) + weight
                relations = existing.get("relations", [])
                if t["predicate"] not in relations:
                    relations.append(t["predicate"])
                    existing["relations"] = relations
            else:
                self._spo_graph.add_edge(
                    subj_name, obj_name,
                    relation=t["predicate"], relations=[t["predicate"]],
                    weight=weight, source="spo",
                )
                edges_created += 1

            # Combined graph (undirected, for backwards compat)
            if not self._graph.has_node(subj_name):
                self._graph.add_node(subj_name, type="entity", label=subj_name)
            if not self._graph.has_node(obj_name):
                self._graph.add_node(obj_name, type="entity", label=obj_name)

            if self._graph.has_edge(subj_name, obj_name):
                existing = self._graph[subj_name][obj_name]
                existing["weight"] = existing.get("weight", 1.0) + weight
                relations = existing.get("relations", [])
                if t["predicate"] not in relations:
                    relations.append(t["predicate"])
                    existing["relations"] = relations
            else:
                self._graph.add_edge(
                    subj_name, obj_name,
                    relation=t["predicate"], relations=[t["predicate"]],
                    weight=weight, source="spo",
                )

        return edges_created

    def _cross_reference_triples(self) -> int:
        """Match triples to wiki articles that mention the same entities.

        For each active triple, if both subject and object entities appear
        in a wiki article's [[wikilinks]], that article "expresses" the triple.

        Builds bidirectional index:
        - triple gets wiki_refs (article paths)
        - _article_triples maps article path -> triple IDs
        """
        xref_count = 0

        # Clear existing wiki_refs on triples
        for t in self._triples:
            t.pop("wiki_refs", None)

        for t in self._triples:
            # Cross-reference active AND superseded/contested triples
            # (superseded triples indicate stale article content)
            if t["status"] not in ("active", "superseded", "contested"):
                continue

            subj_slug = t["subject"]
            obj_slug = t.get("object")  # None for literals

            refs = []
            for article_path, article_slugs in self._article_entities.items():
                if subj_slug in article_slugs:
                    # Subject is mentioned in this article
                    if obj_slug is None or obj_slug in article_slugs:
                        # Both subject and object (or literal triple's subject) present
                        refs.append(article_path)
                        self._article_triples.setdefault(article_path, [])
                        if t["id"] not in self._article_triples[article_path]:
                            self._article_triples[article_path].append(t["id"])

            if refs:
                t["wiki_refs"] = refs
                xref_count += 1

        return xref_count

    def get_article_triples(self, article_path: str) -> list[dict]:
        """Get all triples that are expressed in a wiki article."""
        triple_ids = self._article_triples.get(article_path, [])
        results = []
        for t in self._triples:
            if t["id"] in triple_ids and t["status"] == "active":
                results.append({
                    "id": t["id"],
                    "subject": self._registry.get_name(t["subject"]),
                    "predicate": t["predicate"],
                    "object": self._registry.get_name(t.get("object")) or t.get("object_literal"),
                    "confidence": t["confidence"],
                    "authority": t["authority"],
                    "epistemic_status": t.get("epistemic_status", "believed"),
                })
        return results

    def get_triple_articles(self, triple_id: int) -> list[str]:
        """Get all wiki articles that express a given triple."""
        for t in self._triples:
            if t["id"] == triple_id:
                return t.get("wiki_refs", [])
        return []

    # ── Temporal queries ──

    def query_entity_at(
        self, entity: str, date: str, time_type: str = "world",
    ) -> dict | None:
        """Query what was known about an entity at a specific point in time.

        time_type is a bi-temporal selector (Graphiti-inspired, 2026-04-24):

        - ``"world"`` (default, backwards compatible): returns triples that
          were TRUE in reality at the given date. Uses `valid_from` /
          `valid_until` — the world-time axis.
          Filter: ``valid_from <= date AND (valid_until is None OR valid_until > date)``.

        - ``"system"``: returns what we BELIEVED at the given date. Uses
          `t_created` / `t_expired` — the system-time axis. Answers
          "what was in my knowledge base on date X" even if the fact
          was already stale in reality or we later learned it was wrong.
          Filter: ``t_created <= date AND (t_expired is None OR t_expired > date)``.

        Both axes are orthogonal. A fact can be world-true but
        system-expired (we retracted our belief) or world-expired but
        system-current (we still believe a stale fact — usually a bug
        the dream phases eventually catch).
        """
        slug = self._registry.resolve(entity)
        if not slug:
            return None

        ent = self._registry.entities[slug]
        facts: dict[str, list[dict]] = {}
        use_system = time_type == "system"

        for t in self._triples:
            if t["subject"] != slug and t.get("object") != slug:
                continue

            if use_system:
                # System-time axis: when was this row "alive in our knowledge"?
                # Missing t_created is treated permissively (pre-migration row,
                # assume it was always there) so legacy rows never vanish.
                t_created = t.get("t_created")
                t_expired = t.get("t_expired")
                if t_created and t_created > date:
                    continue  # not yet known at this date
                if t_expired and t_expired <= date:
                    continue  # already invalidated in our KB at this date
                axis_from = t_created
                axis_until = t_expired
                axis_from_key = "t_created"
                axis_until_key = "t_expired"
            else:
                # World-time axis (legacy default).
                valid_from = t.get("valid_from", t.get("source_date", ""))
                valid_until = t.get("valid_until")
                if valid_from and valid_from > date:
                    continue
                if valid_until and valid_until <= date:
                    continue
                axis_from = valid_from
                axis_until = valid_until
                axis_from_key = "valid_from"
                axis_until_key = "valid_until"

            pred = t["predicate"]
            if t["subject"] == slug:
                obj_display = self._registry.get_name(t.get("object")) or t.get("object_literal", "?")
                facts.setdefault(pred, []).append({
                    "value": obj_display,
                    "confidence": t["confidence"],
                    "authority": t["authority"],
                    "epistemic_status": t.get("epistemic_status", "believed"),
                    axis_from_key: axis_from,
                    axis_until_key: axis_until,
                })

        if not facts:
            return None

        return {
            "entity": {
                "name": ent["canonical_name"],
                "type": ent.get("entity_type", "thing"),
            },
            "as_of": date,
            "time_type": "system" if use_system else "world",
            "facts": facts,
        }

    # ── Confidence propagation ──

    def propagate_change(self, triple_id: int) -> dict:
        """When a triple changes status, cascade along justification chains.

        Epistemic cascade follows two paths (in order of priority):
        1. Justification dependencies: triples whose justified_by includes the
           changed triple are directly invalidated (their premise is gone).
        2. Source-file heuristic (secondary): triples from the same source get
           downgraded to "uncertain" as a weaker guilt-by-association signal.
           This is a fallback for triples without explicit justification links.

        Verified facts are immune to cascade — they have independent justification
        from user confirmation.
        """
        triple = None
        for t in self._triples:
            if t["id"] == triple_id:
                triple = t
                break
        if not triple:
            return {"affected_articles": [], "affected_triples": [], "triple_id": triple_id}

        changed_status = triple.get("epistemic_status", "believed")
        is_negative = changed_status in ("superseded", "contested", "retracted")

        affected_triples = []
        cascade_truncated = False
        _max_cascade_depth = _load_memory_policy()["max_cascade_depth"]

        if is_negative:
            visited = {triple_id}

            # Phase 1: Follow justification chains (proper belief revision).
            # If triple A justified triple B, and A is retracted, B loses its ground.
            # Depth-limited BFS prevents cycles and unbounded cascades.
            # Fix #9: Don't break on depth limit — skip the deep node but continue
            # processing other nodes in the queue. Also mark triples beyond the
            # limit so they can be identified as having incomplete justification.
            queue: list[tuple[int, int]] = [(triple_id, 0)]  # (id, depth)
            while queue:
                current_id, depth = queue.pop(0)
                if depth >= _max_cascade_depth:
                    # Don't break — other items in queue at shallower depth may still need processing.
                    # Mark this as truncated so the caller knows the cascade was incomplete.
                    cascade_truncated = True
                    log.warning(
                        "Cascade depth limit (%d) reached at triple #%d — "
                        "marking as cascade_truncated",
                        _max_cascade_depth, current_id,
                    )
                    continue  # skip this node but keep processing the queue
                for t in self._triples:
                    if t["id"] in visited or t["status"] != "active":
                        continue
                    if t.get("epistemic_status") in ("verified", "superseded", "retracted"):
                        continue  # Verified facts have independent justification
                    if current_id in t.get("justified_by", []):
                        # This triple's justification is invalidated
                        new_status = "uncertain"
                        # If we're AT the depth limit, mark as cascade_incomplete
                        # so health checks can detect triples with unresolved dependencies
                        if depth + 1 >= _max_cascade_depth:
                            t["cascade_incomplete"] = True
                        t["epistemic_status"] = new_status
                        visited.add(t["id"])
                        queue.append((t["id"], depth + 1))
                        affected_triples.append({
                            "id": t["id"],
                            "subject": self._registry.get_name(t["subject"]),
                            "predicate": t["predicate"],
                            "object": self._registry.get_name(t.get("object")) or t.get("object_literal"),
                            "new_status": new_status,
                            "reason": f"justified by invalidated triple #{current_id}",
                        })

            # Phase 2: Source-file heuristic (weaker signal, secondary).
            # Only affects triples NOT already caught by justification chains
            # and NOT already verified.
            # Tightened: must share BOTH source file AND subject entity.
            # A job change for Wolf shouldn't make "Alice knows Bob" uncertain
            # just because both facts came from the same meeting notes.
            if triple.get("source_files"):
                invalidated_subject = triple["subject"]
                for t in self._triples:
                    if t["id"] in visited or t["status"] != "active":
                        continue
                    if t.get("epistemic_status") in ("verified", "superseded", "retracted"):
                        continue
                    # Must share the same subject entity (not just source file)
                    if t["subject"] != invalidated_subject:
                        continue
                    shared_sources = set(t.get("source_files", [])) & set(triple.get("source_files", []))
                    if shared_sources:
                        t["epistemic_status"] = "uncertain"
                        visited.add(t["id"])
                        affected_triples.append({
                            "id": t["id"],
                            "subject": self._registry.get_name(t["subject"]),
                            "predicate": t["predicate"],
                            "object": self._registry.get_name(t.get("object")) or t.get("object_literal"),
                            "new_status": "uncertain",
                            "reason": f"shares source with invalidated triple #{triple_id}",
                        })

        if affected_triples:
            self._triples_dirty = True

        # Find affected wiki articles
        affected_articles = list(triple.get("wiki_refs", []))
        subj_slug = triple["subject"]
        for article_path, article_slugs in self._article_entities.items():
            if subj_slug in article_slugs and article_path not in affected_articles:
                affected_articles.append(article_path)

        return {
            "triple_id": triple_id,
            "status": triple.get("status"),
            "epistemic_status": changed_status,
            "subject": self._registry.get_name(triple["subject"]),
            "predicate": triple["predicate"],
            "affected_articles": affected_articles,
            "affected_triples": affected_triples,
            "cascaded": len(affected_triples),
            "cascade_truncated": cascade_truncated,  # Fix #9: True if depth limit was hit
        }

    def get_stale_articles(self) -> list[dict]:
        """Find wiki articles that may contain outdated information.

        An article is potentially stale if any of its backing triples have been
        superseded, contested, or retracted.
        """
        stale = []
        seen = set()

        for article_path, triple_ids in self._article_triples.items():
            if article_path in seen:
                continue

            stale_triples = []
            for t in self._triples:
                if t["id"] not in triple_ids:
                    continue
                es = t.get("epistemic_status", "believed")
                status = t.get("status", "active")
                if es in ("superseded", "contested", "retracted") or status in ("superseded", "decayed"):
                    stale_triples.append({
                        "id": t["id"],
                        "subject": self._registry.get_name(t["subject"]),
                        "predicate": t["predicate"],
                        "object": self._registry.get_name(t.get("object")) or t.get("object_literal"),
                        "status": t.get("epistemic_status"),
                    })

            if stale_triples:
                seen.add(article_path)
                stale.append({
                    "article": article_path,
                    "stale_triples": stale_triples,
                    "total_triples": len(triple_ids),
                })

        stale.sort(key=lambda x: len(x["stale_triples"]), reverse=True)
        return stale

    # ── Query methods (backwards compat) ──

    def get_connected(self, entity: str, depth: int = 2) -> list[dict]:
        """Get all entities connected within N hops (combined graph)."""
        if entity not in self._graph:
            for node in self._graph.nodes:
                if node.lower() == entity.lower():
                    entity = node
                    break
            else:
                return []

        connected = []
        visited = {entity}
        queue = [(entity, 0)]

        while queue:
            current, d = queue.pop(0)
            if d > 0:
                node_data = self._graph.nodes[current]
                edge_data = self._graph.edges.get((entity, current), {})
                connected.append({
                    "entity": current,
                    "type": node_data.get("type", "unknown"),
                    "label": node_data.get("label", current),
                    "distance": d,
                    "sources": sorted(self._node_sources.get(current, set())),
                    "relations": edge_data.get("relations", [edge_data.get("relation", "connected")]),
                    "weight": edge_data.get("weight", 1.0),
                })

            if d < depth:
                for neighbor in self._graph.neighbors(current):
                    if neighbor not in visited:
                        visited.add(neighbor)
                        queue.append((neighbor, d + 1))

        connected.sort(key=lambda x: (-x.get("weight", 1.0), x["distance"]))
        return connected

    def search_entities(self, query: str) -> list[dict]:
        """Search for entities by name. Combines registry search with graph data."""
        query_lower = query.lower()
        results = []
        seen = set()

        # Search NetworkX nodes
        for node in self._graph.nodes:
            node_data = self._graph.nodes[node]
            if node_data.get("type") == "entity" and query_lower in node.lower():
                results.append({
                    "entity": node,
                    "connections": self._graph.degree(node),
                    "sources": sorted(self._node_sources.get(node, set())),
                })
                seen.add(node.lower())

        # Search registry entities
        for slug, ent in self._registry.entities.items():
            name = ent["canonical_name"]
            if name.lower() in seen:
                continue
            match = query_lower in name.lower()
            if not match:
                match = any(query_lower in a.lower() for a in ent.get("aliases", []))
            if match:
                triple_count = sum(
                    1 for t in self._triples
                    if t["status"] == "active" and (t["subject"] == slug or t.get("object") == slug)
                )
                results.append({
                    "entity": name,
                    "entity_type": ent.get("entity_type", "thing"),
                    "authority": ent.get("authority", "inferred"),
                    "connections": self._graph.degree(name) if name in self._graph else 0,
                    "triples": triple_count,
                    "sources": sorted(self._node_sources.get(name, set())),
                })

        results.sort(key=lambda x: x.get("connections", 0) + x.get("triples", 0), reverse=True)
        return results

    def get_graph_data(self) -> dict:
        """Get full graph as nodes + edges for UI visualization."""
        # Build entity_type lookup from registry — index by name, slug, and lowercase
        etype_map: dict[str, str] = {}
        if self._registry:
            for ent in self._registry.list_all():
                name = ent.get("name", "")
                slug = ent.get("slug", "")
                etype = ent.get("entity_type", "thing")
                if etype and etype != "thing":
                    if name:
                        etype_map[name] = etype
                        etype_map[name.lower()] = etype
                    if slug:
                        etype_map[slug] = etype

        # Also index entity_type from wiki article frontmatter (node attributes)
        for node in self._graph.nodes:
            data = self._graph.nodes[node]
            node_etype = data.get("entity_type", "")
            if node_etype and node_etype not in ("", "source_summary", "synthesis", "decision_record"):
                etype_map[node] = node_etype
                etype_map[data.get("label", node)] = node_etype

        nodes = []
        seen_node_ids: set[str] = set()
        for node in self._graph.nodes:
            data = self._graph.nodes[node]
            label = data.get("label", node)
            # Try: exact label, exact node id, lowercase label, hyphenated label
            entity_type = (
                etype_map.get(label) or etype_map.get(node)
                or etype_map.get(label.lower())
                or etype_map.get(label.lower().replace(" ", "-"))
                or ""
            )
            nd: dict = {
                "id": node,
                "label": label,
                "type": data.get("type", "unknown"),
                "connections": self._graph.degree(node),
            }
            if entity_type:
                nd["entity_type"] = entity_type
            nodes.append(nd)
            seen_node_ids.add(node)

        # Surface registry-backed entities that aren't yet in the combined
        # graph — happens when the only triples touching them lack a valid
        # object (e.g. light-dream episodes where the LLM identifies a
        # person but doesn't extract a full relationship). Without this,
        # such entities exist in the SPO layer + entity registry but are
        # invisible in the UI graph view.
        if self._registry:
            for ent in self._registry.list_all():
                # EntityRegistry stores the human-readable label as
                # ``canonical_name`` (not ``name``). list_all() merges
                # slug into each record for us.
                name = ent.get("canonical_name") or ent.get("name") or ""
                slug = ent.get("slug", "")
                if not name:
                    continue
                # The combined graph may already have a node keyed by the
                # canonical name OR by the slug — skip either form to
                # avoid duplicating nodes that are already present.
                if name in seen_node_ids or (slug and slug in seen_node_ids):
                    continue
                etype = ent.get("entity_type", "thing")
                nd = {
                    "id": name,
                    "label": name,
                    "type": "entity",
                    "connections": 0,
                }
                if etype:
                    nd["entity_type"] = etype
                nodes.append(nd)
                seen_node_ids.add(name)
                if slug:
                    etype_map.setdefault(slug, etype)

        edges = []
        for u, v in self._graph.edges:
            edge_data = self._graph.edges[u, v]
            edges.append({
                "source": u,
                "target": v,
                "relation": edge_data.get("relation", "related"),
                "relations": edge_data.get("relations", []),
                "weight": edge_data.get("weight", 1.0),
                "semantic": edge_data.get("source") == "spo",
            })

        # Fallback: if the combined graph is empty (build() hasn't run yet,
        # or the server crashed mid-build last boot), surface the persisted
        # SPO triples directly so the user sees edges immediately instead of
        # an empty graph. Reads are O(triples) but the serialization is the
        # same shape so the UI is none the wiser. Runs only when in-memory
        # edges are zero — once build() completes, this branch never fires.
        if not edges and self._triples:
            seen_nodes_set = {n["id"] for n in nodes}
            for t in self._triples:
                if t.get("status") in ("retracted", "superseded"):
                    continue
                subject = t.get("subject", "")
                obj = t.get("object", "")
                predicate = t.get("predicate", "related_to")
                if not subject or not obj:
                    continue
                # Ensure endpoints exist as nodes — the registry loop above
                # surfaces registered entities but triples may reference
                # entities that weren't registered (e.g. concept-class
                # values that were deduped). Without synthesising these
                # the UI drops the edge silently.
                for endpoint in (subject, obj):
                    if endpoint not in seen_nodes_set:
                        nodes.append({
                            "id": endpoint,
                            "label": endpoint,
                            "type": "entity",
                            "connections": 0,
                            "entity_type": etype_map.get(endpoint, "thing"),
                        })
                        seen_nodes_set.add(endpoint)
                edges.append({
                    "source": subject,
                    "target": obj,
                    "relation": predicate,
                    "relations": [predicate],
                    "weight": 1.0,
                    "semantic": True,
                })

        return {
            "nodes": nodes,
            "edges": edges,
            "node_count": len(nodes),
            "edge_count": len(edges),
        }

    def get_stats(self) -> dict:
        """Graph statistics including SPO triple data."""
        entities = [n for n, d in self._graph.nodes(data=True) if d.get("type") == "entity"]
        files = [n for n, d in self._graph.nodes(data=True) if d.get("type") == "file"]

        most_connected = sorted(
            [(n, self._graph.degree(n)) for n in entities],
            key=lambda x: x[1], reverse=True,
        )[:10]

        active_triples = [t for t in self._triples if t["status"] == "active"]
        decayed_triples = [t for t in self._triples if t.get("status") == "decayed"]
        superseded_triples = [t for t in self._triples if t.get("status") == "superseded"]

        authority_counts: dict[str, int] = {}
        for t in active_triples:
            auth = t.get("authority", "inferred")
            authority_counts[auth] = authority_counts.get(auth, 0) + 1

        epistemic_counts: dict[str, int] = {}
        for t in active_triples:
            es = t.get("epistemic_status", "believed")
            epistemic_counts[es] = epistemic_counts.get(es, 0) + 1

        knowledge_type_counts: dict[str, int] = {}
        for t in active_triples:
            kt = t.get("knowledge_type", "declarative")
            knowledge_type_counts[kt] = knowledge_type_counts.get(kt, 0) + 1

        registry_stats = self._registry.stats()

        return {
            "total_nodes": self._graph.number_of_nodes(),
            "total_edges": self._graph.number_of_edges(),
            "wiki_entities": len(entities),
            "files": len(files),
            "wikilink_edges": self._wikilink_graph.number_of_edges(),
            "spo_edges": self._spo_graph.number_of_edges(),
            "most_connected": [{"entity": n, "connections": c} for n, c in most_connected],
            "spo": {
                "entities": registry_stats["total"],
                "entity_types": registry_stats["by_type"],
                "active_triples": len(active_triples),
                "decayed_triples": len(decayed_triples),
                "superseded_triples": len(superseded_triples),
                "authority_breakdown": authority_counts,
                "epistemic_breakdown": epistemic_counts,
                "knowledge_types": knowledge_type_counts,
            },
            "extraction_quality": self.get_extraction_metrics(),
            # Phase 6 — last-build observability. Empty until first build()
            # completes; otherwise carries mode + timing + delta counters.
            "last_build": dict(self._last_build_summary),
        }
