Natural Memory NM2.1: 记忆路由器分叉、数据集缺陷修复与全轴评测证据

- 引入 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,读写关闭时与原生模型逐位相同
This commit is contained in:
WpyQwq
2026-09-19 11:11:31 +08:00
commit 643e22ecb9
484 changed files with 306821 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
"""Merge the v6 corpus with the zero-overlap corpus into one feature bank.
Why a merge and not a joint encode: the v6 bank holds 2,124,552 rows whose values
were produced by a frozen encoder that must never change (they are the reference
features every earlier measurement was taken on). Re-encoding them would both cost
hours and risk silent drift, so the merged bank is built by **copying verified rows
and appending the new ones**, with the copy spot-checked byte-for-byte afterwards.
Invariants enforced here:
* every row of every source bank is copied exactly once, or skipped only because the
identical text already has a row -- and skipped rows are proven byte-equal;
* ``len(index) == bank rows`` (the trainer refuses a bank where they differ);
* every text referenced by the merged train/eval files resolves to a bank row;
* the merged manifest carries the sha256 of the *merged* dataset files, because the
trainer validates the bank against those frozen inputs.
Usage::
python -m V2_dpskw.build_replay_corpus ^
--source-data v6=data/router_training_v6 --source-bank v6=H:\\Memory\\nm_cache\\nm_router_v6\\feature_cache ^
--source-data zov=data/zero_overlap --source-bank zov=H:\\Memory\\nm_cache\\nm_zero_overlap\\feature_cache ^
--output-data data/router_replay_v7 --output-bank H:\\Memory\\nm_cache\\nm_replay_v7\\feature_cache
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
from pathlib import Path
import numpy as np
BANK_NAME = "features.f16.npy"
INDEX_NAME = "index.json"
MANIFEST_NAME = "manifest.json"
TEXTS_NAME = "texts.jsonl"
LENGTHS_NAME = "lengths.i32"
CHUNK_ROWS = 65536
def text_key(text: str) -> str:
return hashlib.sha1(str(text).encode("utf-8", "replace")).hexdigest()
def sha256_of(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1 << 22), b""):
digest.update(block)
return digest.hexdigest()
def iter_episode_texts(path: Path):
with path.open("r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
row = json.loads(line)
query = str(row.get("query", "")).strip()
if query:
yield query
for candidate in row.get("candidates") or []:
text = str(candidate.get("text", "")).strip()
if text:
yield text
def concat_datasets(sources: list[tuple[str, Path]], output: Path) -> dict:
output.parent.mkdir(parents=True, exist_ok=True)
episodes = 0
with output.open("wb") as out:
for name, directory in sources:
part = directory / output.name
if not part.exists():
raise FileNotFoundError(f"missing {part}")
with part.open("rb") as handle:
shutil.copyfileobj(handle, out, length=1 << 22)
with part.open("rb") as handle:
episodes += sum(1 for _ in handle)
return {"path": str(output), "episodes": episodes, "sha256": sha256_of(output)}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-data", action="append", required=True,
help="NAME=DIR containing train.jsonl and eval.jsonl")
parser.add_argument("--source-bank", action="append", required=True,
help="NAME=DIR containing a verified feature bank")
parser.add_argument("--output-data", required=True)
parser.add_argument("--output-bank", required=True)
parser.add_argument("--model-path", default="qwen3_5_4b_natural_memory_v2")
parser.add_argument("--max-key-tokens", type=int, default=256)
parser.add_argument("--spot-checks", type=int, default=2000)
args = parser.parse_args()
data_sources = []
for item in args.source_data:
name, _, directory = item.partition("=")
data_sources.append((name, Path(directory)))
bank_sources = []
for item in args.source_bank:
name, _, directory = item.partition("=")
bank_sources.append((name, Path(directory)))
if [n for n, _ in data_sources] != [n for n, _ in bank_sources]:
raise SystemExit("--source-data and --source-bank must list the same names in the same order")
out_data = Path(args.output_data)
out_bank = Path(args.output_bank)
out_bank.mkdir(parents=True, exist_ok=True)
merged_datasets = {
"train": concat_datasets([(n, d) for n, d in data_sources], out_data / "train.jsonl"),
"eval": concat_datasets([(n, d) for n, d in data_sources], out_data / "eval.jsonl"),
}
print(json.dumps({"phase": "datasets", **merged_datasets}, ensure_ascii=False), flush=True)
# --- plan the merged row layout -------------------------------------------------
layout: list[tuple[int, int, str]] = [] # (source index, source row, sha1)
origin: list[tuple[int, int]] = [] # (source index, source row) per merged row
texts: list[str] = []
key_to_row: dict[str, int] = {}
duplicates: list[tuple[str, int, int]] = []
source_meta = []
for index, (name, directory) in enumerate(bank_sources):
manifest = json.loads((directory / MANIFEST_NAME).read_text(encoding="utf-8"))
if manifest.get("complete") is not True:
raise SystemExit(f"source bank {name} is not marked complete: {directory}")
lookup = json.loads((directory / INDEX_NAME).read_text(encoding="utf-8"))
bank = np.load(directory / BANK_NAME, mmap_mode="r")
if int(bank.shape[0]) != len(lookup):
raise SystemExit(f"source bank {name}: rows {bank.shape[0]} != index {len(lookup)}")
source_meta.append({"name": name, "path": str(directory), "rows": int(bank.shape[0]),
"hidden_size": int(bank.shape[1]), "dtype": str(bank.dtype),
"manifest_model_path": manifest.get("model_path")})
row_to_key = [None] * int(bank.shape[0])
for key, row in lookup.items():
row_to_key[int(row)] = key
length_path = directory / LENGTHS_NAME
# lengths.i32 is a raw int32 dump (written with ndarray.tofile), not a .npy.
lengths = np.fromfile(length_path, dtype=np.int32) if length_path.exists() else None
if lengths is not None and int(lengths.shape[0]) != int(bank.shape[0]):
raise SystemExit(f"source bank {name}: lengths {lengths.shape[0]} != rows {bank.shape[0]}")
seen_lines = 0
with (directory / TEXTS_NAME).open("r", encoding="utf-8") as handle:
for row, line in enumerate(handle):
seen_lines = row + 1
text = json.loads(line)
key = row_to_key[row]
if key is None:
raise SystemExit(f"source bank {name}: row {row} has no index entry")
if key != text_key(text):
raise SystemExit(f"source bank {name}: texts.jsonl line {row} does not match its index key")
if key in key_to_row:
duplicates.append((name, index, row))
continue
key_to_row[key] = len(origin)
origin.append((index, row))
texts.append(text)
if seen_lines != int(bank.shape[0]):
raise SystemExit(f"source bank {name}: texts.jsonl has {seen_lines} lines, bank has {bank.shape[0]} rows")
del row_to_key, lookup, bank, lengths
total = len(origin)
print(json.dumps({"phase": "layout", "merged_rows": total,
"sources": source_meta,
"duplicate_rows_skipped": len(duplicates)}, ensure_ascii=False), flush=True)
# --- copy the feature rows -----------------------------------------------------
hidden = source_meta[0]["hidden_size"]
if any(meta["hidden_size"] != hidden for meta in source_meta):
raise SystemExit("source banks disagree on hidden size")
banks = [np.load(directory / BANK_NAME, mmap_mode="r") for _, directory in bank_sources]
target = np.lib.format.open_memmap(out_bank / BANK_NAME, mode="w+",
dtype=np.float16, shape=(total, hidden))
lengths_out = np.empty(total, dtype=np.int32)
length_arrays = []
for _, directory in bank_sources:
path = directory / LENGTHS_NAME
length_arrays.append(np.fromfile(path, dtype=np.int32) if path.exists() else None)
by_source: dict[int, list[tuple[int, int]]] = {}
for merged_row, (source_index, source_row) in enumerate(origin):
by_source.setdefault(source_index, []).append((merged_row, source_row))
for source_index in sorted(by_source):
pairs = by_source[source_index]
bank = banks[source_index]
lengths = length_arrays[source_index]
# Copy contiguous source runs so the disk sees sequential reads.
start = 0
while start < len(pairs):
end = start + 1
while (end < len(pairs)
and pairs[end][1] == pairs[end - 1][1] + 1
and pairs[end][0] == pairs[end - 1][0] + 1):
end += 1
run = pairs[start:end]
if len(run) > CHUNK_ROWS:
for offset in range(0, len(run), CHUNK_ROWS):
piece = run[offset:offset + CHUNK_ROWS]
rows = [p[1] for p in piece]
target[[p[0] for p in piece]] = bank[rows[0]:rows[-1] + 1]
else:
rows = [p[1] for p in run]
target[[p[0] for p in run]] = bank[rows[0]:rows[-1] + 1]
start = end
if lengths is not None:
for merged_row, source_row in pairs:
lengths_out[merged_row] = lengths[source_row]
print(json.dumps({"phase": "copied_source", "name": source_meta[source_index]["name"],
"rows": len(pairs)}, ensure_ascii=False), flush=True)
target.flush()
del target
# --- index, texts, lengths -----------------------------------------------------
(out_bank / INDEX_NAME).write_text(json.dumps(key_to_row), encoding="utf-8")
with (out_bank / TEXTS_NAME).open("w", encoding="utf-8") as handle:
for text in texts:
handle.write(json.dumps(text, ensure_ascii=False) + "\n")
lengths_out.tofile(out_bank / LENGTHS_NAME)
print(json.dumps({"phase": "written", "index_entries": len(key_to_row)}, ensure_ascii=False), flush=True)
# --- verification --------------------------------------------------------------
problems: list[str] = []
merged = np.load(out_bank / BANK_NAME, mmap_mode="r")
if int(merged.shape[0]) != len(key_to_row):
problems.append(f"rows {merged.shape[0]} != index entries {len(key_to_row)}")
rng = np.random.default_rng(20260912)
checked = 0
for merged_row in rng.choice(total, size=min(args.spot_checks, total), replace=False):
source_index, source_row = origin[int(merged_row)]
if not np.array_equal(np.asarray(merged[merged_row]), np.asarray(banks[source_index][source_row])):
problems.append(f"row {merged_row} differs from {source_meta[source_index]['name']}:{source_row}")
if len(problems) > 5:
break
checked += 1
zero_rows = 0
for start in range(0, total, CHUNK_ROWS):
block = np.asarray(merged[start:start + CHUNK_ROWS])
zero_rows += int((~block.any(axis=1)).sum())
if zero_rows:
problems.append(f"{zero_rows} merged rows are all-zero (unencoded)")
missing = 0
for split in ("train", "eval"):
for text in iter_episode_texts(out_data / f"{split}.jsonl"):
if text_key(text) not in key_to_row:
missing += 1
if missing:
problems.append(f"{missing} dataset texts have no bank row")
manifest = {
"format_version": 2,
"merged_by": "build_replay_corpus.py",
"train_sha256": merged_datasets["train"]["sha256"],
"eval_sha256": merged_datasets["eval"]["sha256"],
"model_path": args.model_path,
"max_key_tokens": int(args.max_key_tokens),
"hidden_size": hidden,
"text_count": total,
"encoded_rows": total,
"complete": True,
"dtype": "float16_memmap",
"bank": BANK_NAME,
"index": INDEX_NAME,
"texts": TEXTS_NAME,
"sources": source_meta,
"duplicate_rows_skipped": len(duplicates),
"verification": {
"spot_checked_rows": checked,
"spot_check_mismatches": [p for p in problems if "differs" in p],
"zero_rows": zero_rows,
"dataset_texts_without_bank_row": missing,
"passed": not problems,
},
"datasets": merged_datasets,
}
(out_bank / MANIFEST_NAME).write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(manifest, ensure_ascii=False, indent=2), flush=True)
if problems:
raise SystemExit("merged bank failed verification: " + "; ".join(problems))
return 0
if __name__ == "__main__":
raise SystemExit(main())