Skip to content

Commit 2daa56f

Browse files
tiberius1701dclaude
andcommitted
feat(memory): simulate mode persists per-tier graph.json; weight reconstruction on mode flip
Simulate mode now stores its per-tier indexed-key facts as data/ha/simulate/<tier>/graph.json instead of keyed_pairs.json. The storage medium is a NetworkX MultiDiGraph; each fact is an edge with {key, predicate, speaker_id, first_seen_cycle} as edge data and the (subject, object) as endpoints. The train->simulate migration no longer copies the on-disk sidecar; it reconstructs the per-tier graph by probing every active key from the adapter weights via reconstruct_graph(loop, tier=..., strict=True). The simulate-mode artifact is therefore guaranteed to encode exactly what the model knows, never a stale sidecar. The simulate->train migration reads graph.json instead of keyed_pairs.json and trains the new adapter against the same per-edge content. Train mode is intentionally untouched in this change: the adapter-side keyed_pairs.json still exists and is the canonical store for the trained tiers. A follow-up retires it. Other changes that land alongside: - pytest conftest sets CUDA_VISIBLE_DEVICES="" by default and auto-deselects @pytest.mark.gpu tests, so non-gpu runs no longer open /dev/dxg or allocate a cuBLAS workspace. Opt in with --gpu or -m gpu. - The in-RAM cache attribute and seed methods drop the legacy _qa suffix (loop.indexed_key_qa -> loop.indexed_key_cache; seed_<tier>_qa -> seed_<tier>_cache). The contents are quad-shape uniform entries since the QA->quad migration. - Server config rejects consolidation.mode="simulate" combined with indexed_format="qa" — the simulate-mode store is quad-only by construction. Round-trip equivalence verified on a real Mistral 7B adapter: G_cache (production simulate writer) == G_weights (post simulate-to-train- to-simulate), with the train side probing only the weights. Same keys, same content, same iteration order, same edge data. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a8b329d commit 2daa56f

38 files changed

Lines changed: 3987 additions & 1215 deletions

experiments/dataset_probe.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ def _prepare_smoke_shim(run_dir: Path, loop) -> Path:
421421
shim_dir.mkdir(parents=True, exist_ok=True)
422422

423423
# (a) keyed_pairs.json
424-
_write_keyed_pairs(loop.indexed_key_qa, loop.episodic_simhash, shim_dir / "keyed_pairs.json")
424+
_write_keyed_pairs(loop.indexed_key_cache, loop.episodic_simhash, shim_dir / "keyed_pairs.json")
425425

426426
# (b) simhash_registry.json
427427
src_registry = run_dir / "simhash_registry_episodic.json"

paramem/graph/reconstruct.py

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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

Comments
 (0)