|
| 1 | +"""Reconstruct a knowledge graph from ConsolidationLoop adapter weights. |
| 2 | +
|
| 3 | +This module provides a pure function that probes every active key from a |
| 4 | +ConsolidationLoop's adapter weights, parses the recalled quadruple, and |
| 5 | +merges the results into a fresh ``nx.MultiDiGraph``. |
| 6 | +
|
| 7 | +The graph produced here is intentionally less rich than the original |
| 8 | +extraction graph. The adapter weights encode only the quad |
| 9 | +``(subject, predicate, object)`` per key; temporal metadata, speaker |
| 10 | +attribution, and entity-resolution attributes are not re-derived from |
| 11 | +weights. Callers that need those fields must read them from the |
| 12 | +KeyRegistry or the graph merger — not from this reconstruction. |
| 13 | +
|
| 14 | +Typical use: migrate a ConsolidationLoop from ``simulate`` mode to ``train`` |
| 15 | +mode by extracting the embedded knowledge graph from the adapter weights |
| 16 | +rather than copying on-disk ``keyed_pairs.json`` sidecars. |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import logging |
| 22 | +from dataclasses import dataclass, field |
| 23 | + |
| 24 | +import networkx as nx |
| 25 | + |
| 26 | +from paramem.models.loader import switch_adapter |
| 27 | +from paramem.training.quadruple_memory import probe_quad |
| 28 | + |
| 29 | +logger = logging.getLogger(__name__) |
| 30 | + |
| 31 | + |
| 32 | +@dataclass(frozen=True) |
| 33 | +class ReconstructionResult: |
| 34 | + """Outcome of probing every active key from the adapter weights. |
| 35 | +
|
| 36 | + Attributes: |
| 37 | + graph: Fresh ``nx.MultiDiGraph``. Each successfully recalled quad |
| 38 | + becomes an edge ``(subject) -> (object)`` with edge data |
| 39 | + ``{"key": str, "predicate": str}``. Nodes are created on-demand |
| 40 | + with no additional attributes. |
| 41 | + failures: List of dicts for every key that could not be recalled. |
| 42 | + Each dict carries ``{"key", "adapter_id", "raw_output", |
| 43 | + "failure_reason"}``. Empty in the strict success path. |
| 44 | + """ |
| 45 | + |
| 46 | + graph: nx.MultiDiGraph |
| 47 | + failures: list[dict] = field(default_factory=list) |
| 48 | + |
| 49 | + |
| 50 | +class ReconstructionError(RuntimeError): |
| 51 | + """Raised when ``strict=True`` and any key failed to recall. |
| 52 | +
|
| 53 | + The error message includes the count of failures and the first three |
| 54 | + ``{key, failure_reason}`` pairs for quick diagnosis. |
| 55 | + """ |
| 56 | + |
| 57 | + |
| 58 | +def reconstruct_graph( |
| 59 | + loop, |
| 60 | + *, |
| 61 | + tier: str | None = None, |
| 62 | + strict: bool = True, |
| 63 | +) -> ReconstructionResult: |
| 64 | + """Probe every active key from the loop's adapter weights and build a graph. |
| 65 | +
|
| 66 | + Iterates ``loop.indexed_key_registry.list_active()``, groups keys by |
| 67 | + their ``adapter_id``, calls ``switch_adapter`` once per group, probes |
| 68 | + every key in that group via :func:`~paramem.training.quadruple_memory.probe_quad`, |
| 69 | + and merges the quads into a fresh ``nx.MultiDiGraph``. |
| 70 | +
|
| 71 | + The function is read-only on ``loop.model``: after all probes complete it |
| 72 | + restores the adapter that was active before the first switch. |
| 73 | +
|
| 74 | + Gradient checkpointing is disabled around the probing loop and re-enabled |
| 75 | + in a ``try/finally`` — HF silently disables the KV cache when |
| 76 | + checkpointing is active (CLAUDE.md: applies to ANY ``model.generate()`` |
| 77 | + site). |
| 78 | +
|
| 79 | + Args: |
| 80 | + loop: A ``ConsolidationLoop`` instance. Must have |
| 81 | + ``_indexed_format == "quad"``; QA mode raises |
| 82 | + :exc:`NotImplementedError`. Reads: |
| 83 | +
|
| 84 | + - ``loop.model`` — PEFT model (adapter switches applied in-place). |
| 85 | + - ``loop.tokenizer`` — tokenizer matching the model. |
| 86 | + - ``loop.indexed_key_registry`` — :class:`~paramem.training.key_registry.KeyRegistry` |
| 87 | + that maps active keys to adapter IDs. |
| 88 | + - ``loop.{episodic,semantic,procedural}_simhash`` — per-adapter |
| 89 | + SimHash registry dicts (may be ``None`` or absent). |
| 90 | +
|
| 91 | + tier: If set (``"episodic"`` | ``"semantic"`` | ``"procedural"``), |
| 92 | + only probe keys whose ``registry.get_adapter_id(key) == tier``. |
| 93 | + ``None`` reconstructs all active keys across all adapters. |
| 94 | + strict: When ``True`` (default), raise :exc:`ReconstructionError` if |
| 95 | + any key failed to recall. When ``False``, record failures in |
| 96 | + ``ReconstructionResult.failures`` and continue. |
| 97 | +
|
| 98 | + Returns: |
| 99 | + :class:`ReconstructionResult` with: |
| 100 | +
|
| 101 | + - ``graph`` — fresh ``nx.MultiDiGraph``; each successful quad becomes |
| 102 | + an edge ``(subject) -> (object)`` with edge data ``{"key": str, |
| 103 | + "predicate": str}``. |
| 104 | + - ``failures`` — list of failed-probe dicts (empty when all keys |
| 105 | + succeed). |
| 106 | +
|
| 107 | + Raises: |
| 108 | + NotImplementedError: When ``loop._indexed_format != "quad"``. |
| 109 | + ReconstructionError: When ``strict=True`` and at least one key failed. |
| 110 | +
|
| 111 | + Contract: |
| 112 | + Every key in ``registry.list_active()`` (filtered by ``tier``) is |
| 113 | + either represented in ``graph`` as a successful edge or listed in |
| 114 | + ``failures``. No key is silently dropped. |
| 115 | + """ |
| 116 | + _indexed_format = getattr(loop, "_indexed_format", "qa") |
| 117 | + if _indexed_format != "quad": |
| 118 | + raise NotImplementedError( |
| 119 | + f"reconstruct_graph supports indexed_format='quad' only; got {_indexed_format!r}" |
| 120 | + ) |
| 121 | + |
| 122 | + registry = loop.indexed_key_registry |
| 123 | + active_keys: list[str] = registry.list_active() |
| 124 | + |
| 125 | + # Apply tier filter before building the groups. |
| 126 | + if tier is not None: |
| 127 | + active_keys = [k for k in active_keys if registry.get_adapter_id(k) == tier] |
| 128 | + |
| 129 | + if not active_keys: |
| 130 | + logger.debug("reconstruct_graph: no active keys to probe (tier=%r)", tier) |
| 131 | + return ReconstructionResult(graph=nx.MultiDiGraph(), failures=[]) |
| 132 | + |
| 133 | + # Group keys by adapter_id so we switch only once per adapter. |
| 134 | + keys_by_adapter: dict[str, list[str]] = {} |
| 135 | + for key in active_keys: |
| 136 | + adapter_id = registry.get_adapter_id(key) |
| 137 | + keys_by_adapter.setdefault(adapter_id, []).append(key) |
| 138 | + |
| 139 | + model = loop.model |
| 140 | + tokenizer = loop.tokenizer |
| 141 | + |
| 142 | + # Capture the currently-active adapter so we can restore it. PEFT 0.18+ |
| 143 | + # normally exposes a string here, but some PeftModel layouts return a list |
| 144 | + # (the same defensive unwrap is used in ``app.py`` around |
| 145 | + # ``model.active_adapter``) — defending against that here keeps the restore |
| 146 | + # path passing a string to ``switch_adapter`` under any PEFT minor-version. |
| 147 | + _raw_active = model.active_adapter |
| 148 | + if isinstance(_raw_active, list): |
| 149 | + original_adapter: str | None = _raw_active[0] if _raw_active else None |
| 150 | + else: |
| 151 | + original_adapter = _raw_active |
| 152 | + |
| 153 | + graph = nx.MultiDiGraph() |
| 154 | + failures: list[dict] = [] |
| 155 | + |
| 156 | + # Disable gradient checkpointing around all probing — HF silently disables |
| 157 | + # the KV cache when checkpointing is active, which causes silent generation |
| 158 | + # degradation (CLAUDE.md rule applies to ANY model.generate() site). |
| 159 | + model.gradient_checkpointing_disable() |
| 160 | + try: |
| 161 | + for adapter_id, keys in keys_by_adapter.items(): |
| 162 | + # Per-adapter SimHash registry: ``{tier}_simhash`` attribute on |
| 163 | + # the loop. Interim adapter IDs (``episodic_interim_<stamp>``) |
| 164 | + # don't have a dedicated simhash; we pass None (probe_quad |
| 165 | + # defaults confidence to 1.0 when registry is None). |
| 166 | + simhash_registry = getattr(loop, f"{adapter_id}_simhash", None) |
| 167 | + |
| 168 | + logger.debug( |
| 169 | + "reconstruct_graph: switching to adapter %r, probing %d keys", |
| 170 | + adapter_id, |
| 171 | + len(keys), |
| 172 | + ) |
| 173 | + switch_adapter(model, adapter_id) |
| 174 | + |
| 175 | + for key in keys: |
| 176 | + result = probe_quad( |
| 177 | + model, |
| 178 | + tokenizer, |
| 179 | + key, |
| 180 | + registry=simhash_registry, |
| 181 | + ) |
| 182 | + |
| 183 | + if "failure_reason" in result: |
| 184 | + logger.debug( |
| 185 | + "reconstruct_graph: key %r failed (%s)", |
| 186 | + key, |
| 187 | + result["failure_reason"], |
| 188 | + ) |
| 189 | + failures.append( |
| 190 | + { |
| 191 | + "key": key, |
| 192 | + "adapter_id": adapter_id, |
| 193 | + "raw_output": result.get("raw_output", ""), |
| 194 | + "failure_reason": result["failure_reason"], |
| 195 | + } |
| 196 | + ) |
| 197 | + else: |
| 198 | + subject = result["subject"] |
| 199 | + predicate = result["predicate"] |
| 200 | + obj = result["object"] |
| 201 | + # nx.MultiDiGraph uses ``key`` as the edge identifier |
| 202 | + # parameter in add_edge, so we cannot pass it as a keyword |
| 203 | + # argument directly (it becomes the multigraph edge-key, not |
| 204 | + # edge data). Instead: add the edge, capture the |
| 205 | + # auto-assigned integer edge-key, then set the indexed-memory |
| 206 | + # key on that specific edge's data dict — under |
| 207 | + # ``_IK_KEY_ATTR`` so the value survives ``nx.node_link_data`` |
| 208 | + # round-trips through ``paramem.server.simulate_store`` |
| 209 | + # (NetworkX reserves the JSON field ``"key"`` for the |
| 210 | + # multigraph edge identifier; using ``"key"`` here would be |
| 211 | + # silently clobbered on save→load). |
| 212 | + from paramem.server.simulate_store import _IK_KEY_ATTR |
| 213 | + |
| 214 | + eid = graph.add_edge(subject, obj, predicate=predicate) |
| 215 | + graph[subject][obj][eid][_IK_KEY_ATTR] = key |
| 216 | + logger.debug( |
| 217 | + "reconstruct_graph: key %r → (%r, %r, %r)", |
| 218 | + key, |
| 219 | + subject, |
| 220 | + predicate, |
| 221 | + obj, |
| 222 | + ) |
| 223 | + finally: |
| 224 | + # Always restore the original adapter, even on exception. Skip when |
| 225 | + # no adapter was active to begin with (e.g. PEFT returned an empty |
| 226 | + # list) — switch_adapter requires a real name. |
| 227 | + if original_adapter is not None: |
| 228 | + switch_adapter(model, original_adapter) |
| 229 | + # Re-enable gradient checkpointing if the loop's training config |
| 230 | + # has it turned on. Mirror the pattern from consolidation.py's |
| 231 | + # _enable_gradient_checkpointing helper. |
| 232 | + training_config = getattr(loop, "training_config", None) |
| 233 | + if training_config is not None and getattr( |
| 234 | + training_config, "gradient_checkpointing", False |
| 235 | + ): |
| 236 | + model.gradient_checkpointing_enable( |
| 237 | + gradient_checkpointing_kwargs={"use_reentrant": False} |
| 238 | + ) |
| 239 | + |
| 240 | + if strict and failures: |
| 241 | + sample = failures[:3] |
| 242 | + summary = "; ".join(f"key={f['key']!r} reason={f['failure_reason']!r}" for f in sample) |
| 243 | + raise ReconstructionError( |
| 244 | + f"reconstruct_graph: {len(failures)} key(s) failed to recall. " |
| 245 | + f"First failures: [{summary}]" |
| 246 | + ) |
| 247 | + |
| 248 | + return ReconstructionResult(graph=graph, failures=failures) |
0 commit comments