merge(data): 並行鎖/consolidate 保護/情緒校驗/speechLint 誤殺修正
This commit is contained in:
+227
-1
@@ -8,7 +8,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -213,6 +213,58 @@ check("關鍵詞可檢索到",
|
||||
pl.recall("alpha", "早上 會議").map((m) => m._name).join() === "hates-morning-meetings");
|
||||
check("情緒事件寫進 journal", pl.readJsonl(pl.journalPath("alpha")).some((r) => r.kind === "emotion"));
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// consolidate 的兩個資料遺失路徑
|
||||
{
|
||||
// ① `--forget N` 以前是無條件刪掉 salience < N 的全部短期記憶,完全不套 shortTermProtected():
|
||||
// `--forget 200` 會把一筆 intent=commit、salience 95、剛寫入的承諾一起刪掉,而 R4 寫的是「不可遺忘」。
|
||||
const stFile = path.join(H, "forgetter", "memory", "short-term.jsonl");
|
||||
fs.mkdirSync(path.dirname(stFile), { recursive: true });
|
||||
const stale = pl.iso(new Date(Date.now() - 5 * 86_400_000));
|
||||
fs.writeFileSync(stFile, [
|
||||
{ ts: pl.nowIso(), text: "我答應你週五之前把那件事做完", salience: 95, intent: "commit" },
|
||||
{ ts: stale, text: "界線:不要替我做決定", salience: 88 },
|
||||
{ ts: pl.nowIso(), text: "今天剛講的閒聊", salience: 10 },
|
||||
{ ts: stale, text: "上週的閒聊", salience: 10 },
|
||||
].map((r) => JSON.stringify(r)).join("\n") + "\n");
|
||||
const cut = pl.forgetShortTerm("forgetter", 200);
|
||||
const left = pl.readJsonl(stFile).map((r) => r.text);
|
||||
check("`--forget` 不刪 intent=commit 的承諾(R4:不可遺忘)",
|
||||
left.includes("我答應你週五之前把那件事做完"), left.join("|"));
|
||||
check("`--forget` 不刪顯著度 ≥ 80 的界線", left.includes("界線:不要替我做決定"));
|
||||
check("`--forget` 不刪 24 小時內的新紀錄(還沒機會被固化)", left.includes("今天剛講的閒聊"));
|
||||
check("`--forget` 該刪的還是有刪,而且會回報保護了幾筆",
|
||||
!left.includes("上週的閒聊") && cut.dropped === 1 && cut.protected === 3, JSON.stringify(cut));
|
||||
|
||||
// ② slugify 把標點吃掉:「我的貓」「我的貓?」「我的貓!!」都落在同一個檔名上,
|
||||
// 以前 writeText 直接覆蓋,只留舊檔的 first_seen/recall_count,內文靜默被換掉。
|
||||
check("slugify 確實會讓這三個名字撞在一起",
|
||||
pl.slugify("我的貓") === pl.slugify("我的貓?") && pl.slugify("我的貓") === pl.slugify("我的貓!!"));
|
||||
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓", "--body", "牠叫小黑。"]);
|
||||
const catFile = path.join(pl.longTermDir("alpha"), `${pl.slugify("我的貓")}.md`);
|
||||
check("撞名的第二則會被擋下,不會靜默覆蓋",
|
||||
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
|
||||
"--body", "完全不同的內容。"], { expectOk: false }).status !== 0);
|
||||
check("被擋下之後舊的內文原封不動",
|
||||
fs.readFileSync(catFile, "utf8").includes("牠叫小黑。") &&
|
||||
!fs.readFileSync(catFile, "utf8").includes("完全不同的內容。"));
|
||||
check("擋下的訊息會給一個沒被占用的檔名",
|
||||
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
|
||||
"--body", "x"], { expectOk: false }).stderr.includes(`--name ${pl.slugify("我的貓")}-2`),
|
||||
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
|
||||
"--body", "x"], { expectOk: false }).stderr.slice(0, 200));
|
||||
check("同一個 --name 再寫一次是更新,不算撞名",
|
||||
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓",
|
||||
"--body", "牠叫小黑,十二歲。"]).status === 0 &&
|
||||
fs.readFileSync(catFile, "utf8").includes("十二歲"));
|
||||
check("front matter 留著原本的 --name(下次靠這行認出不是同一則)",
|
||||
pl.parseFrontMatter(fs.readFileSync(catFile, "utf8"))[0].title === "我的貓");
|
||||
check("真的要覆蓋還是走得通(--force)",
|
||||
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
|
||||
"--body", "換成別則了。", "--force"]).status === 0 &&
|
||||
fs.readFileSync(catFile, "utf8").includes("換成別則了。"));
|
||||
}
|
||||
|
||||
console.log("⑥ 短期 → 長期的轉入條件");
|
||||
check("有六條成文條件", pl.PROMOTION_RULES.length === 6);
|
||||
check("R1 高顯著度會成為候選",
|
||||
@@ -505,6 +557,11 @@ const SF_CASES = [
|
||||
["中國用語", "那個視頻我看完了。", "cn"],
|
||||
["半形標點", "我看完了,你呢?", "halfwidth"],
|
||||
["排版殘留", "**重點**:鐘修好了。", "markdown"],
|
||||
// 放寬《》與句長之後,這幾條仍然要擋——豁免只針對「提及/引述」,不是整句放行
|
||||
["書名號之外真的在說教", "我在看《時間簡史》。說到底,問題不在零件。", "preach"],
|
||||
["自己講的長句(引號之外)", `我${"想跟你說一件從去年就一直放在心上沒講出口的事情".repeat(2)}。`, "long"],
|
||||
["兩個 emoji", "修好了 🔧 開心 🎉", "emoji"],
|
||||
["一個 emoji 加一個帶變體選擇子的", "修好了 🔧 給你 ❤️", "emoji"],
|
||||
];
|
||||
for (const [label, text, kind] of SF_CASES) {
|
||||
const issues = pl.speechLint(text);
|
||||
@@ -528,6 +585,16 @@ const SNF_CASES = [
|
||||
["一次「不是 A 而是 B」", "我要的不是最快,而是修得久。"],
|
||||
["一個破折號", "那台鐘——我留著。"],
|
||||
["日常短句", "桌上那杯茶冷了。要不要再泡一杯?"],
|
||||
// 書名號也是「提及 ≠ 使用」:以前豁免清單漏了《》,這句被判 preach
|
||||
["書名裡剛好有黑名單的詞", "我在看《說到底》這本書。"],
|
||||
["書名裡的中國用語", "他推薦我看《質量與信息》。"],
|
||||
// ❤️ 是 U+2764 + VS16;以前 EMOJI_RE 含 \u{FE0F},這一個字被算成兩個而誤擋
|
||||
["帶變體選擇子的單一 emoji", "這個給你 ❤️"],
|
||||
["ZWJ 組成的單一 emoji", "路上遇到他們 👨👩👦"],
|
||||
["帶膚色的單一 emoji", "收到 👍🏽"],
|
||||
// 句長以前算的是整句(含引號內容),引述使用者原話必被擋
|
||||
["引述使用者的長原話", `他傳來「${"我今天真的很累而且不知道要從哪裡開始講起".repeat(2)}」,我看了兩次。`],
|
||||
["引述長 code", `跑的是 \`${"x".repeat(80)}\` 這一行。`],
|
||||
];
|
||||
for (const [label, text] of SNF_CASES) {
|
||||
const issues = pl.speechBlockers(text);
|
||||
@@ -731,6 +798,165 @@ console.log("\n短期記憶:容量壓力(R6)真的會清,而且有保護
|
||||
typeof detail.dropped === "number" && typeof detail.protected === "number", JSON.stringify(detail));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 並行寫入不掉資料
|
||||
//
|
||||
// 人格鎖擋的是「兩個人格同時被載入」,不是「兩個程序同時寫同一個檔」——同一個 session
|
||||
// 的 sub agent 與主程序共用同一把人格鎖(設計如此),所以兩邊真的會同時寫。
|
||||
// 修之前:背景 prune 進行中寫入 30 筆 salience 95 的承諾會掉 1~9 筆;
|
||||
// 並行 8 次 `emotion --apply joy=+5` 大約有四成的增量消失。
|
||||
console.log("\n並行寫入:read-modify-rewrite 不能吃掉同時 append 的資料");
|
||||
{
|
||||
const LIB = path.join(HERE, "persona-lib.mjs");
|
||||
// 所有子程序睡到同一個時間點才起跑,不然它們只會一個接一個跑、撞不在一起
|
||||
const BARRIER = `
|
||||
const _b = new Int32Array(new SharedArrayBuffer(4));
|
||||
const waitUntil = (t) => { const d = t - Date.now(); if (d > 0) Atomics.wait(_b, 0, 0, d); };
|
||||
`;
|
||||
const child = (code) =>
|
||||
new Promise((resolve) => {
|
||||
const p = spawn(process.execPath, ["--input-type=module", "-e", code],
|
||||
{ env: { ...process.env }, stdio: "ignore" });
|
||||
p.on("exit", resolve);
|
||||
});
|
||||
|
||||
// ① prune 的整檔覆蓋 vs. 同時 append 的受保護紀錄
|
||||
const raceHome = path.join(H, "racer", "memory");
|
||||
const raceFile = path.join(raceHome, "short-term.jsonl");
|
||||
fs.mkdirSync(raceHome, { recursive: true });
|
||||
const staleTs = pl.iso(new Date(Date.now() - 5 * 86_400_000));
|
||||
fs.writeFileSync(raceFile, Array.from({ length: 200 }, (_, i) =>
|
||||
JSON.stringify({ ts: staleTs, text: `舊${i}`, salience: 10, intent: "chat" })).join("\n") + "\n");
|
||||
let startAt = Date.now() + 700;
|
||||
await Promise.all([
|
||||
child(`${BARRIER}
|
||||
const pl = await import(${JSON.stringify(LIB)});
|
||||
waitUntil(${startAt});
|
||||
for (let i = 0; i < 300; i += 1) pl.pruneShortTermDetail("racer");`),
|
||||
child(`${BARRIER}
|
||||
const pl = await import(${JSON.stringify(LIB)});
|
||||
waitUntil(${startAt});
|
||||
const b = new Int32Array(new SharedArrayBuffer(4));
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
pl.rememberShort("racer", { text: "承諾 " + i, salience: 95, intent: "commit" });
|
||||
Atomics.wait(b, 0, 0, 5);
|
||||
}`),
|
||||
// 一邊有人在聊天,prune 才會一路都有事做(不然它清到軟上限就不再覆寫了)
|
||||
child(`${BARRIER}
|
||||
const pl = await import(${JSON.stringify(LIB)});
|
||||
waitUntil(${startAt});
|
||||
const ts = pl.iso(new Date(Date.now() - 5 * 86400000));
|
||||
for (let i = 0; i < 400; i += 1) pl.appendJsonl(pl.shortTermPath("racer"), { ts, text: "雜訊 " + i, salience: 5 });`),
|
||||
]);
|
||||
const survivors = pl.readJsonl(raceFile).filter((r) => String(r.text || "").startsWith("承諾"));
|
||||
check("背景 prune 進行中 append 的承諾,一筆都不能掉",
|
||||
survivors.length === 30, `活下 ${survivors.length}/30 筆`);
|
||||
|
||||
// ② emotion.json 的 read-modify-write:並行要跟循序算出同一個值
|
||||
cli(["create", "--persona", "racee", "--session", "sess-race-9999", "--name", "Racee",
|
||||
"--creature", "沙漏", "--vibe", "安靜", "--emoji", "⏳"]);
|
||||
const joyOf = () => pl.readJson(pl.emotionPath("racee")).levels.joy;
|
||||
const resetJoy = () => pl.writeJson(pl.emotionPath("racee"), pl.defaultEmotionState());
|
||||
resetJoy();
|
||||
for (let i = 0; i < 8; i += 1) cli(["emotion", "--session", "sess-race-9999", "--apply", "joy=+5"]);
|
||||
const sequential = joyOf();
|
||||
resetJoy();
|
||||
startAt = Date.now() + 900;
|
||||
await Promise.all(Array.from({ length: 8 }, () => child(`${BARRIER}
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
waitUntil(${startAt});
|
||||
spawnSync(process.execPath, [${JSON.stringify(CLI)}, "emotion",
|
||||
"--session", "sess-race-9999", "--apply", "joy=+5"], { stdio: "ignore" });`)));
|
||||
const parallel = joyOf();
|
||||
check("並行 8 次 `emotion --apply` 跟循序 8 次結果一樣(情緒更新不遺失)",
|
||||
Math.abs(parallel - sequential) < 0.01, `循序 ${sequential} / 並行 ${parallel}`);
|
||||
cli(["release", "--session", "sess-race-9999"]);
|
||||
|
||||
// ③ withFileLock 本身:N 個程序各做一次 read-increment-write,一次都不能掉
|
||||
const counter = path.join(H, "racer", "counter.json");
|
||||
fs.writeFileSync(counter, JSON.stringify({ n: 0 }));
|
||||
startAt = Date.now() + 700;
|
||||
await Promise.all(Array.from({ length: 12 }, () => child(`${BARRIER}
|
||||
const pl = await import(${JSON.stringify(LIB)});
|
||||
waitUntil(${startAt});
|
||||
pl.updateJson(${JSON.stringify(counter)}, (d) => {
|
||||
const n = (d && d.n) || 0;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 8); // 撐開讀與寫之間的空窗
|
||||
return { n: n + 1 };
|
||||
}, { n: 0 });`)));
|
||||
check("withFileLock 真的互斥:12 個程序各加一次,結果就是 12",
|
||||
pl.readJson(counter).n === 12, `n=${pl.readJson(counter).n}`);
|
||||
|
||||
// ④ 殘留的鎖檔不能讓人格從此寫不進東西
|
||||
const victim = path.join(H, "racer", "stale.jsonl");
|
||||
fs.writeFileSync(victim, "");
|
||||
const staleLock = pl.fileLockPath(victim);
|
||||
fs.mkdirSync(path.dirname(staleLock), { recursive: true });
|
||||
fs.writeFileSync(staleLock, "999999\n");
|
||||
const old = new Date(Date.now() - pl.FILE_LOCK_STALE_MS - 5_000);
|
||||
fs.utimesSync(staleLock, old, old);
|
||||
pl.appendJsonl(victim, { text: "死鎖之後還是要寫得進去" });
|
||||
check("過期的鎖檔會被接手,不會把人格鎖死", pl.readJsonl(victim).length === 1);
|
||||
check("鎖檔放在 .runtime/locks,不會混進人格目錄(也就不會被同步上去)",
|
||||
pl.fileLockPath(victim).startsWith(path.join(pl.runtimeDir(), "locks")) &&
|
||||
!fs.existsSync(staleLock), pl.fileLockPath(victim));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 情緒的檔案入口要驗證:emotion.json 是外部輸入(import/sync pull/手改都會進來),
|
||||
// CLI 的 --baseline/--apply 有保護,破口在檔案這一側。
|
||||
console.log("\n情緒:不合法的 emotion.json 不能污染整個狀態");
|
||||
{
|
||||
cli(["create", "--persona", "badstate", "--session", "sess-bad-8888", "--name", "Bad",
|
||||
"--creature", "壞掉的溫度計", "--vibe", "亂跳", "--emoji", "🌡"]);
|
||||
const anHourAgo = pl.iso(new Date(Date.now() - 3_600_000));
|
||||
const write = (patch) => {
|
||||
const st = pl.defaultEmotionState();
|
||||
st.updated_at = anHourAgo;
|
||||
patch(st);
|
||||
pl.writeJson(pl.emotionPath("badstate"), st);
|
||||
};
|
||||
|
||||
// ① 超出範圍的 baseline:以前 decay 後 joy = 4378,mood 卡在 valence 100 / arousal 100
|
||||
write((st) => { st.baseline.joy = 5000; });
|
||||
const over = pl.decayEmotion(pl.loadEmotion("badstate"));
|
||||
check("baseline 超出 0–100 會被 clamp(不會把 mood 頂到滿)",
|
||||
over.baseline.joy === 100 && over.levels.joy <= 100 && pl.mood(over).valence < 100,
|
||||
`baseline=${over.baseline.joy} joy=${over.levels.joy} valence=${pl.mood(over).valence}`);
|
||||
|
||||
// ② 非數字的 levels:以前 decay 後 anger = NaN,注入的上下文直接印出 valence NaN
|
||||
write((st) => { st.levels.anger = "很生氣"; st.levels.joy = null; });
|
||||
const nan = pl.decayEmotion(pl.loadEmotion("badstate"));
|
||||
check("非數字的情緒值退回預設,不會變成 NaN",
|
||||
Number.isFinite(nan.levels.anger) && Number.isFinite(nan.levels.joy), JSON.stringify(nan.levels));
|
||||
check("注入的上下文不會印出 NaN",
|
||||
!pl.emotionBrief("badstate", nan).includes("NaN") && Number.isFinite(pl.mood(nan).valence),
|
||||
pl.emotionBrief("badstate", nan));
|
||||
|
||||
// ③ 半衰期 ≤ 0:以前 factor 恆為 0,那個情緒從此累積不起來(連三次 +30 結果都一樣)
|
||||
for (const bad of [-10, 0, "很久", null]) {
|
||||
write((st) => { st.half_life_minutes.joy = bad; });
|
||||
check(`half_life=${JSON.stringify(bad)} 會退回預設半衰期`,
|
||||
pl.loadEmotion("badstate").half_life_minutes.joy === pl.EMOTIONS.joy.halfLife);
|
||||
}
|
||||
write((st) => { st.half_life_minutes.joy = -10; });
|
||||
const grow = [];
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
cli(["emotion", "--session", "sess-bad-8888", "--apply", "joy=+30"]);
|
||||
grow.push(pl.readJson(pl.emotionPath("badstate")).levels.joy);
|
||||
}
|
||||
check("半衰期壞掉時情緒仍然累積得起來(以前三次 +30 一動也不動)",
|
||||
grow[1] > grow[0] && grow[2] > grow[1], grow.join(" → "));
|
||||
|
||||
// ④ 睡眠的固定衰減走同一條路
|
||||
write((st) => { st.half_life_minutes.sadness = 0; st.levels.sadness = 90; });
|
||||
const slept = pl.decayEmotionBy(pl.loadEmotion("badstate"), pl.SLEEP_DECAY_MINUTES);
|
||||
check("decayEmotionBy 也擋得住壞掉的半衰期(不會一覺把情緒歸零)",
|
||||
slept.levels.sadness > pl.DEFAULT_BASELINE.sadness && slept.levels.sadness < 90,
|
||||
`sadness=${slept.levels.sadness}`);
|
||||
cli(["release", "--session", "sess-bad-8888"]);
|
||||
}
|
||||
|
||||
console.log("\n情緒:飽和、單輪預算、走向、偏差稽核");
|
||||
let sat = pl.defaultEmotionState();
|
||||
for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌");
|
||||
|
||||
Reference in New Issue
Block a user