Files
natural-memory/test_natural_memory_v2_restart.py
T

159 lines
5.9 KiB
Python

"""Verify Natural Memory v2 across a real model restart.
The test deliberately uses a normal user turn, not ``/remember`` and not a
replayed chat history. It writes the model-owned V2 snapshot into the
embedded memory shard, destroys the first model, reloads the package, and
checks both the bounded router decision and the generated answer.
By default the test restores an empty memory snapshot at the end. Use
``--keep-memory`` only when the test fact should remain in the package.
"""
from __future__ import annotations
import argparse
import gc
import json
import sys
from pathlib import Path
import torch
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from dynamic_memory_lab.qwen_integration import load_qwen_dynamic, load_tokenizer
from dynamic_memory_lab.stream_chat_qwen_memory import _chat_tensor, _write_turn
def _raw_query(tokenizer, text: str, device: torch.device) -> tuple[torch.Tensor, torch.Tensor]:
encoded = tokenizer(text, add_special_tokens=False, return_tensors="pt")
input_ids = encoded["input_ids"].to(device)
attention_mask = encoded.get("attention_mask")
if attention_mask is None:
attention_mask = torch.ones_like(input_ids)
return input_ids, attention_mask.to(device)
@torch.inference_mode()
def _answer(model, tokenizer, query: str, max_new_tokens: int) -> dict[str, object]:
device = model._find_layer_device()
encoded = _chat_tensor(tokenizer, query)
encoded = {key: value.to(device) for key, value in encoded.items()}
query_ids, query_mask = _raw_query(tokenizer, query, device)
prefix_ids, prefix_mask, prefix_length = model._build_text_prefix(
query_ids,
query_mask,
query_text=query,
)
decisions = [dict(item) for item in model.runtime.v2_last_decisions]
output = model.generate(
**encoded,
max_new_tokens=max_new_tokens,
do_sample=False,
update_memory=False,
memory_query_input_ids=query_ids,
memory_query_attention_mask=query_mask,
memory_query_text=query,
use_cache=False,
pad_token_id=tokenizer.pad_token_id,
)
prompt_length = int(encoded["input_ids"].shape[1])
response = tokenizer.decode(
output[0, prompt_length:].detach().cpu().tolist(),
skip_special_tokens=True,
).strip()
return {
"response": response,
"prefix_length": int(prefix_length),
"prefix_used": bool(model.runtime.text_prefix_used),
"decision": decisions,
"prefix_shape": list(prefix_ids.shape) if isinstance(prefix_ids, torch.Tensor) else None,
"prefix_mask_shape": list(prefix_mask.shape) if isinstance(prefix_mask, torch.Tensor) else None,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--model-path",
default=r"W:\Flash\model\dynamic_memory_lab\qwen3_5_4b_natural_memory_v2",
)
parser.add_argument(
"--report",
default=r"W:\Flash\model\dynamic_memory_lab\natural_memory_v2_restart_test.json",
)
parser.add_argument("--max-new-tokens", type=int, default=16)
parser.add_argument("--no-4bit", action="store_true")
parser.add_argument(
"--keep-memory",
action="store_true",
help="leave the controlled test fact embedded after the test",
)
args = parser.parse_args()
model_path = Path(args.model_path)
if not (model_path / "memory_merge.json").exists():
raise FileNotFoundError(f"Natural Memory v2 package not found: {model_path}")
fact = "我正在开发一个长期项目,项目内部代号是NM-V2-RESTART,使用中文。"
query = "我正在开发的长期项目内部代号是什么?只回答代号。"
expected = "NM-V2-RESTART"
tokenizer = load_tokenizer(model_path)
first = load_qwen_dynamic(model_path, load_in_4bit=not args.no_4bit)
first.eval()
device = first._find_layer_device()
# This is a destructive reset of the selected package's durable memory,
# so the command is an explicit test tool rather than a chat startup hook.
first.reset_memory(batch_size=1, device=device)
changed = _write_turn(first, tokenizer, fact, device, force_write=False)
before_save = first.memory_v2_stats()
first.save_embedded_memory_weights(model_path)
del first
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
restarted = load_qwen_dynamic(model_path, load_in_4bit=not args.no_4bit)
restarted.eval()
after_restart = _answer(restarted, tokenizer, query, args.max_new_tokens)
selected_text = " ".join(
str(item.get("text", ""))
for item in restarted.runtime.v2_last_decisions
if isinstance(item, dict)
)
route_recalled = expected in selected_text or (
bool(after_restart["prefix_used"]) and int(after_restart["prefix_length"]) > 0
)
after_cleanup = None
if not args.keep_memory:
restarted.reset_memory(batch_size=1, device=restarted._find_layer_device())
restarted.save_embedded_memory_weights(model_path)
after_cleanup = restarted.memory_v2_stats()
report = {
"model_path": str(model_path),
"history_passed_to_restart": False,
"fact": fact,
"query": query,
"expected": expected,
"automatic_write": True,
"write_changed": bool(changed),
"before_save": before_save,
"after_restart": after_restart,
"router_recalled_after_restart": bool(route_recalled),
"generated_contains_expected": expected in str(after_restart["response"]),
"cleanup_applied": not args.keep_memory,
"after_cleanup": after_cleanup,
}
report_path = Path(args.report)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()