- 引入 MemoryRouterXL 与 v5/v6 流式多线程训练/编码管线 - 修复 prepare_memory_router_dataset 候选池重建缺陷(mega 家族 3568x 加速,输出逐字节相同) - 修复 v5 被破坏的拒答与多跳标签(train 未知样本 319 -> 16319,multi_hop 平均正例 1.00 -> 2.00) - 同存储预算下 V2-128 v6 逐轴 22/22 通过:Top-1 41.12% -> 94.62%,未知拒答 0.00% -> 100.00% - 记录三条被实测推翻的显然优化(logits_to_keep=1 反而慢 55%、XL 容量未带来收益) - 记忆手术跨架构可移植性 14/14,读写关闭时与原生模型逐位相同
143 lines
6.3 KiB
Python
143 lines
6.3 KiB
Python
"""Verify the write-path fix: unrelated facts survive, real updates still version.
|
|
|
|
The fix under test gates "this write is an update of an existing record" on a
|
|
*structural* same-fact check (the ``entity::attribute`` conflict key the bank already
|
|
tracks) instead of trusting the learned pair score alone. Removing a retraction path is
|
|
only safe if it still retires what it is supposed to, so this probe runs three phases on
|
|
one model load:
|
|
|
|
A. **regression target** -- 20 distinct-attribute facts must leave 20 active records
|
|
(before the fix: 12 active, 8 retracted);
|
|
B. **positive control** -- re-writing the *same* attribute with a new value must still
|
|
version the fact: the previous record becomes ``superseded`` (not destroyed), the new
|
|
value is active, and the other attributes are untouched. Without this, "no
|
|
retractions" could simply mean "updates are broken";
|
|
C. **retriever-absent control** -- with ``_text_retriever_ready = False`` the same 20
|
|
distinct facts must still all stay active. This control matters because the lexical
|
|
fallback branch (>= 0.30) is more permissive than the learned one and is only
|
|
unreachable while the retriever is ready.
|
|
|
|
Usage::
|
|
|
|
python -m V2_dpskw.verify_write_path_fix --package qwen3_5_4b_natural_memory_v2
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
import torch
|
|
|
|
from .eval_end_to_end_memory import write_fact
|
|
from .memory_os_v2 import STATUS_ACTIVE, STATUS_SUPERSEDED
|
|
from .qwen_integration import load_memory_config, load_qwen_dynamic, load_tokenizer
|
|
|
|
ATTRIBUTES = [
|
|
"常住城市", "出生城市", "办公城市", "档案标识", "常用编辑器", "默认语言", "通勤方式",
|
|
"主管姓名", "工位楼层", "团队名称", "邮箱域名", "手机尾号", "项目代号", "入职年份",
|
|
"紧急联系人姓氏", "午餐偏好", "运动习惯", "阅读工具", "起床时间", "咖啡口味",
|
|
]
|
|
|
|
|
|
def inventory(model) -> dict:
|
|
records = model.memory_os_v2.bank.records
|
|
statuses = Counter(record.status for record in records.values())
|
|
by_attribute: dict[str, str] = {}
|
|
for record in records.values():
|
|
if record.attribute:
|
|
by_attribute.setdefault(record.attribute, []).append(record.status)
|
|
return {
|
|
"records": len(records),
|
|
"status_counts": dict(statuses),
|
|
"active": statuses.get(STATUS_ACTIVE, 0),
|
|
"superseded": statuses.get(STATUS_SUPERSEDED, 0),
|
|
"retracted": statuses.get("retracted", 0),
|
|
"attribute_status": {k: sorted(v) for k, v in sorted(by_attribute.items())},
|
|
}
|
|
|
|
|
|
def values_for(model, attribute: str) -> list[tuple[str, str]]:
|
|
return [
|
|
(record.value, record.status)
|
|
for record in model.memory_os_v2.bank.records.values()
|
|
if record.attribute == attribute
|
|
]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--package", default="qwen3_5_4b_natural_memory_v2")
|
|
parser.add_argument("--output", default="write_path_fix_verification.json")
|
|
args = parser.parse_args()
|
|
|
|
model_path = Path(args.package)
|
|
memory_config = load_memory_config(model_path)
|
|
model = load_qwen_dynamic(model_path, memory_config=memory_config, load_in_4bit=True,
|
|
max_memory={0: "10.5GiB", "cpu": "48GiB"})
|
|
model.eval()
|
|
tokenizer = load_tokenizer(model_path)
|
|
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
|
|
facts = ["我的%s是 VAL-%07d。" % (attribute, index)
|
|
for index, attribute in enumerate(ATTRIBUTES)]
|
|
report: dict = {"package": str(model_path), "facts": len(facts), "phases": {}}
|
|
checks: dict[str, bool] = {}
|
|
|
|
# --- Phase A: distinct facts must not retire each other -------------------------
|
|
model.reset_memory()
|
|
for fact in facts:
|
|
write_fact(model, tokenizer, fact, device)
|
|
phase_a = inventory(model)
|
|
report["phases"]["A_distinct_facts"] = phase_a
|
|
checks["A_all_distinct_facts_active"] = phase_a["active"] == len(facts)
|
|
checks["A_no_retractions"] = phase_a["retracted"] == 0
|
|
print(json.dumps({"phase": "A", **phase_a}, ensure_ascii=False), flush=True)
|
|
|
|
# --- Phase B: a genuine same-attribute update must still version -----------------
|
|
model.reset_memory()
|
|
for fact in facts:
|
|
write_fact(model, tokenizer, fact, device)
|
|
before = inventory(model)
|
|
write_fact(model, tokenizer, "我的常住城市是 VAL-9999999。", device)
|
|
after = inventory(model)
|
|
city_values = values_for(model, "常住城市")
|
|
report["phases"]["B_positive_control"] = {
|
|
"before": before, "after": after, "常住城市_values": city_values,
|
|
}
|
|
active_city = [value for value, status in city_values if status == STATUS_ACTIVE]
|
|
checks["B_new_value_is_active"] = active_city == ["VAL-9999999"]
|
|
checks["B_old_version_superseded_not_destroyed"] = any(
|
|
status == STATUS_SUPERSEDED for _, status in city_values
|
|
)
|
|
checks["B_other_attributes_untouched"] = after["active"] >= len(facts)
|
|
checks["B_no_retractions"] = after["retracted"] == 0
|
|
print(json.dumps({"phase": "B", "常住城市_values": city_values,
|
|
"active_before": before["active"], "active_after": after["active"],
|
|
"status_counts_after": after["status_counts"]}, ensure_ascii=False), flush=True)
|
|
|
|
# --- Phase C: same behaviour with the learned retriever absent -------------------
|
|
model.reset_memory()
|
|
model._text_retriever_ready = False
|
|
for fact in facts:
|
|
write_fact(model, tokenizer, fact, device)
|
|
phase_c = inventory(model)
|
|
model._text_retriever_ready = True
|
|
report["phases"]["C_retriever_absent"] = phase_c
|
|
checks["C_all_distinct_facts_active_without_retriever"] = phase_c["active"] == len(facts)
|
|
checks["C_no_retractions_without_retriever"] = phase_c["retracted"] == 0
|
|
print(json.dumps({"phase": "C", **phase_c}, ensure_ascii=False), flush=True)
|
|
|
|
report["checks"] = checks
|
|
report["all_checks_passed"] = all(checks.values())
|
|
Path(args.output).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print(json.dumps({"checks": checks, "all_checks_passed": report["all_checks_passed"]},
|
|
ensure_ascii=False, indent=2), flush=True)
|
|
return 0 if report["all_checks_passed"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|