- 引入 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,读写关闭时与原生模型逐位相同
217 lines
10 KiB
Python
217 lines
10 KiB
Python
"""Router-critical end-to-end test: cases where the rule-based paths cannot decide.
|
|
|
|
The v6 end-to-end harness showed 90.9% of decisions coming from the rule-based
|
|
lexical/address path and 9.1% from the explicit-unknown rule, with the neural
|
|
router deciding **nothing** - which is why two very different routers produced
|
|
byte-identical end-to-end results.
|
|
|
|
This harness builds the opposite condition on purpose:
|
|
|
|
* several facts share the **same attribute wording** (so lexical hits are
|
|
ambiguous and cannot pick a winner on their own);
|
|
* every query identifies its target only by **entity**;
|
|
* the answer is a random code that only the right fact contains, so guessing and
|
|
evidence mixing are both detectable.
|
|
|
|
The router is therefore on the critical path: if it ranks the wrong entity's fact
|
|
first, the model reads the wrong code and the answer is wrong.
|
|
|
|
Usage::
|
|
|
|
python -m V2_dpskw.eval_router_critical_e2e ^
|
|
--router "deployed=" ^
|
|
--router "V2-128-v6=checkpoints/router_v6_v2_128/router_best.pt" ^
|
|
--router "XL-128-v6=checkpoints/router_v6_xl128/router_best.pt" ^
|
|
--output router_critical_e2e.json --markdown router_critical_e2e.md
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import random
|
|
import sys
|
|
import time
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
import torch
|
|
|
|
if __package__ in {None, ""}:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from V2_dpskw.eval_end_to_end_memory import answer, write_fact
|
|
from V2_dpskw.qwen_integration import load_memory_config, load_qwen_dynamic, load_tokenizer
|
|
|
|
#: (fact, paraphrase-query) pairs.
|
|
#:
|
|
#: Two properties make these cases router-critical rather than rule-critical:
|
|
#:
|
|
#: 1. every fact uses a DISTINCT attribute, so the conflict keys differ and all
|
|
#: eight facts genuinely coexist in the bank (an earlier version of this test
|
|
#: reused one attribute, the runtime correctly superseded the older values, and
|
|
#: the test measured overwrite behaviour instead of routing);
|
|
#: 2. each query is a paraphrase that shares no content term with its target fact,
|
|
#: so the address/lexical paths find nothing and the neural router - not a rule -
|
|
#: has to decide whether and what to read.
|
|
#:
|
|
#: The answer is a random code that appears in exactly one fact, so mixing
|
|
#: evidence from a neighbouring attribute is detectable.
|
|
CASE_PAIRS = [
|
|
("我的常住城市是 CITY-A1B2C3。", "我平时待得最久的地方是哪里?"),
|
|
("我的出生城市是 CITY-D4E5F6。", "我老家在哪儿?"),
|
|
("我的办公城市是 CITY-G7H8J9。", "我每天上班要去哪个地方?"),
|
|
("我的档案代号是 CODE-K1L2M3。", "系统给我分配的那串字符是什么?"),
|
|
("我的常用编辑器是 EDIT-N4P5Q6。", "我写代码时用哪个软件?"),
|
|
("我的默认语言是 LANG-R7S8T9。", "我平时讲哪种话?"),
|
|
("我的通勤方式是 COMMU-U1V2W3。", "我早上怎么去公司?"),
|
|
("我的主管姓名是 BOSS-X4Y5Z6。", "谁管我?"),
|
|
("我的工位楼层是 FLOOR-A7B8C9。", "我坐在哪一层?"),
|
|
("我的团队名称是 TEAM-D1E2F3。", "我属于哪个小组?"),
|
|
("我的邮箱域名是 MAIL-G4H5J6。", "别人给我发信要写哪个后缀?"),
|
|
("我的手机尾号是 PHONE-K7L8M9。", "我的电话号码最后几位是什么?"),
|
|
("我的项目代号是 PROJ-N1P2Q3。", "我正在做的那个工程叫什么?"),
|
|
("我的入职年份是 YEAR-R4S5T6。", "我是哪一年来的这家公司?"),
|
|
("我的办公楼层是 DESK-U7V8W9。", "我平时在几楼办公?"),
|
|
("我的紧急联系人姓氏是 KIN-X1Y2Z3。", "出事时该找哪家人?"),
|
|
]
|
|
FILLER_FACTS = [
|
|
"我的工位在 3 号楼。",
|
|
"我的笔记本电脑品牌是 ThinkPad。",
|
|
"我的水杯是蓝色的。",
|
|
"我的鼠标是无线的。",
|
|
]
|
|
|
|
|
|
def _value_of(fact: str) -> str:
|
|
"""The code that only this fact contains."""
|
|
|
|
return fact.split("是", 1)[1].strip().rstrip("。")
|
|
|
|
|
|
def build_cases(count: int, seed: int) -> list[dict]:
|
|
pairs = CASE_PAIRS[: max(1, min(count, len(CASE_PAIRS)))]
|
|
bank = [fact for fact, _ in pairs] + FILLER_FACTS
|
|
cases = []
|
|
for fact, query in pairs:
|
|
cases.append({
|
|
"fact": fact,
|
|
"query": query,
|
|
"code": _value_of(fact),
|
|
"attribute": fact[len("我的"):].split("是", 1)[0],
|
|
"bank": bank,
|
|
})
|
|
return cases
|
|
|
|
|
|
@torch.inference_mode()
|
|
def run_cases(model, tokenizer, cases, device, *, max_new_tokens: int, label: str) -> dict:
|
|
rows = []
|
|
started = time.perf_counter()
|
|
for index, case in enumerate(cases, 1):
|
|
model.reset_memory(batch_size=1, device=device)
|
|
for fact in case["bank"]:
|
|
write_fact(model, tokenizer, fact, device)
|
|
# Prove the case is not solvable by the rule-based paths: ask the bank
|
|
# itself whether this query produces any address or lexical hit.
|
|
bank = model.memory_os_v2.bank
|
|
address_hit = bool(bank.has_explicit_address(case["query"]))
|
|
lexical_pages, lexical_records = bank._lexical_evidence_hits(case["query"])
|
|
reply = answer(model, tokenizer, case["query"], device, max_new_tokens)
|
|
decision = model.runtime.v2_last_decisions[-1] if model.runtime.v2_last_decisions else {}
|
|
wanted = case["code"]
|
|
others = [c["code"] for c in cases if c["code"] != wanted]
|
|
correct = wanted.lower() in reply.lower()
|
|
mixed = (not correct) and any(code.lower() in reply.lower() for code in others)
|
|
rows.append({
|
|
"attribute": case["attribute"], "wanted": wanted, "reply": reply[:160],
|
|
"correct": bool(correct), "wrong_attribute_code": bool(mixed),
|
|
"rule_address_hit": address_hit,
|
|
"rule_lexical_hits": len(lexical_records),
|
|
"stop_reason": str(decision.get("stop_reason")),
|
|
"need_memory": bool(decision.get("need_memory")),
|
|
"top_score": float(decision.get("top_score") or 0.0),
|
|
"records_selected": len(decision.get("record_ids") or []),
|
|
})
|
|
if index % 4 == 0:
|
|
print(json.dumps({"router": label, "case": index, "total": len(cases),
|
|
"accuracy_pct": round(100 * sum(r["correct"] for r in rows) / len(rows), 2)}), flush=True)
|
|
summary = {
|
|
"router": label,
|
|
"cases": len(rows),
|
|
"accuracy_pct": 100 * sum(r["correct"] for r in rows) / max(1, len(rows)),
|
|
"wrong_attribute_pct": 100 * sum(r["wrong_attribute_code"] for r in rows) / max(1, len(rows)),
|
|
"read_pct": 100 * sum(r["need_memory"] for r in rows) / max(1, len(rows)),
|
|
"cases_with_rule_hits": sum(1 for r in rows if r["rule_address_hit"] or r["rule_lexical_hits"]),
|
|
"stop_reasons": dict(Counter(r["stop_reason"] for r in rows)),
|
|
"mean_records_selected": sum(r["records_selected"] for r in rows) / max(1, len(rows)),
|
|
"seconds": round(time.perf_counter() - started, 1),
|
|
}
|
|
return {"summary": summary, "rows": rows}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--package", default="qwen3_5_4b_natural_memory_v2")
|
|
parser.add_argument("--cases", type=int, default=10)
|
|
parser.add_argument("--seed", type=int, default=20260911)
|
|
parser.add_argument("--max-new-tokens", type=int, default=48)
|
|
parser.add_argument(
|
|
"--top-k-records",
|
|
type=int,
|
|
default=0,
|
|
help="override memory_top_k_records; the router's recall is only useful if the "
|
|
"records it injects are precise enough for the model to use",
|
|
)
|
|
parser.add_argument("--router", action="append", required=True)
|
|
parser.add_argument("--output", default="router_critical_e2e.json")
|
|
parser.add_argument("--markdown", default="")
|
|
args = parser.parse_args()
|
|
|
|
cases = build_cases(args.cases, args.seed)
|
|
print(json.dumps({"cases": len(cases), "bank_facts": len(cases[0]["bank"]),
|
|
"note": "all facts share the attribute wording; only the entity differs"}, ensure_ascii=False), flush=True)
|
|
|
|
model_path = Path(args.package)
|
|
memory_config = load_memory_config(model_path)
|
|
if args.top_k_records:
|
|
memory_config.memory_top_k_records = int(args.top_k_records)
|
|
print(json.dumps({"top_k_records_override": args.top_k_records}), flush=True)
|
|
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")
|
|
|
|
results: dict = {}
|
|
for spec in args.router:
|
|
label, _, path_value = spec.partition("=")
|
|
if path_value.strip():
|
|
state = torch.load(path_value.strip(), map_location="cpu", weights_only=True)
|
|
model.memory_router_v2.load_state_dict(state.get("router_state_dict", state), strict=True)
|
|
model.memory_router_v2.to(device).eval()
|
|
print(json.dumps({"router_swapped": label}), flush=True)
|
|
results[label] = run_cases(model, tokenizer, cases, device, max_new_tokens=args.max_new_tokens, label=label)
|
|
print(json.dumps(results[label]["summary"], ensure_ascii=False), flush=True)
|
|
|
|
Path(args.output).write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
lines = ["| 路由器 | 用例 | 回答正确率 | 答成别的属性 | 触发读取 | 规则命中用例 | 平均选中记录 | 决策来源 |",
|
|
"|---|---:|---:|---:|---:|---:|---:|---|"]
|
|
for label, block in results.items():
|
|
s = block["summary"]
|
|
lines.append("| {l} | {c} | {a:.2f}% | {w:.2f}% | {r:.2f}% | {h}/{c} | {m:.2f} | {s} |".format(
|
|
l=label, c=s["cases"], a=s["accuracy_pct"], w=s["wrong_attribute_pct"],
|
|
r=s["read_pct"], h=s["cases_with_rule_hits"], m=s["mean_records_selected"],
|
|
s=", ".join(f"{k}:{v}" for k, v in sorted(s["stop_reasons"].items()))))
|
|
table = "\n".join(lines)
|
|
print("\n" + table, flush=True)
|
|
if args.markdown:
|
|
Path(args.markdown).write_text(table + "\n", encoding="utf-8")
|
|
print(f"wrote {args.markdown}", flush=True)
|
|
print(f"wrote {args.output}", flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|