- 權重 W = I_eff × F × A,暫時/可升級/長期記憶的衰減與封存,任何記憶不刪除 - 未解負向事件反覆思量、開放迴路逾時、危險內容不固化 - 檢索改為關鍵字 + RRF × W + 心情一致加權,保留外部檢索器介面 - 版本 0.0.4 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
338 lines
15 KiB
TypeScript
338 lines
15 KiB
TypeScript
import { describe, test } from 'claude-code/testing'
|
||
import assert from './assert'
|
||
|
||
import { memoryFs } from './io'
|
||
import {
|
||
CONSOLIDATE_THRESHOLD,
|
||
TOP_K,
|
||
access,
|
||
categoryBonus,
|
||
createMemory,
|
||
freshness,
|
||
iEff,
|
||
isDanger,
|
||
lastQuestion,
|
||
redact,
|
||
rrf,
|
||
rumination,
|
||
shouldArchive,
|
||
weight,
|
||
} from './memory'
|
||
import type { Memory } from './memory'
|
||
|
||
const H = 3600_000
|
||
const T0 = Date.UTC(2026, 9, 12, 10, 0)
|
||
|
||
const DAY = 24 * H
|
||
const mk = (o: Partial<Memory> = {}): Memory => ({
|
||
id: 1, layer: 'episodic', kind: 'upgradable', status: 'active', text: '事', importance: 6, source: 'u',
|
||
createdAt: T0, lastAccess: T0, accessCount: 0, seq: 0, ...o,
|
||
})
|
||
|
||
function setup() {
|
||
let now = T0
|
||
const fs = memoryFs()
|
||
const mem = createMemory(fs, () => now, 'mem.json')
|
||
return { mem, fs, advance: (ms: number) => void (now += ms), now: () => now }
|
||
}
|
||
|
||
describe('記憶檢索', () => {
|
||
test('相關性高者排前面', async () => {
|
||
const s = setup()
|
||
await s.mem.add({ layer: 'semantic', text: '使用者喜歡抹茶拿鐵', importance: 5, source: 'u' })
|
||
await s.mem.add({ layer: 'semantic', text: '使用者養了一隻貓', importance: 5, source: 'u' })
|
||
const r = await s.mem.recall('抹茶')
|
||
assert.equal(r[0]!.text, '使用者喜歡抹茶拿鐵')
|
||
})
|
||
|
||
test('較新、較重要者在其他條件相同時勝出', async () => {
|
||
// 回合數相隔越久新鮮度越低;檢索會讓被取回的記憶回到 0.8,所以以權重直接比較
|
||
assert.ok(weight(mk({ seq: 300 }), 300, T0) > weight(mk({ seq: 0 }), 300, T0))
|
||
const s2 = setup()
|
||
await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 2, source: 'u' })
|
||
const hi = await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 9, source: 'u' })
|
||
assert.equal((await s2.mem.recall('測試'))[0]!.id, hi.id)
|
||
})
|
||
|
||
test('情緒吻合度影響排序', async () => {
|
||
const s = setup()
|
||
await s.mem.add({ layer: 'emotional', text: '一起慶祝', importance: 5, source: 'u', emotion: { p: -0.8, a: 0.5, d: -0.5 } })
|
||
const happy = await s.mem.add({ layer: 'emotional', text: '一起慶祝', importance: 5, source: 'u', emotion: { p: 0.8, a: 0.5, d: 0.5 } })
|
||
assert.equal((await s.mem.recall('慶祝', { p: 0.7, a: 0.4, d: 0.4 }))[0]!.id, happy.id)
|
||
})
|
||
|
||
test('最多取前 6 筆,並累計取用次數', async () => {
|
||
const s = setup()
|
||
for (let i = 0; i < 10; i++) await s.mem.add({ layer: 'semantic', text: `筆記${i}`, importance: 5, source: 'u' })
|
||
const r = await s.mem.recall('筆記')
|
||
assert.equal(r.length, TOP_K)
|
||
assert.ok((await s.mem.all()).filter(m => m.accessCount === 1).length === TOP_K)
|
||
})
|
||
|
||
test('寫入前過濾疑似金鑰', async () => {
|
||
const s = setup()
|
||
const m = await s.mem.add({ layer: 'episodic', text: '金鑰是 sk-abcdefghijklmnop1234 請記住', importance: 3, source: 'u' })
|
||
assert.ok(!m.text.includes('sk-abcdef'))
|
||
assert.ok(m.text.includes('[已過濾]'))
|
||
assert.equal(redact('普通的一句話'), '普通的一句話')
|
||
})
|
||
})
|
||
|
||
describe('記憶權重 W = I_eff × F × A', () => {
|
||
test('I_eff = 初始重要度/2 + 情緒峰值/40 + 類別加成,限制在 1–5', () => {
|
||
assert.equal(iEff(mk({ importance: 6 })), 3)
|
||
assert.equal(iEff(mk({ importance: 6, peak: 40 })), 4)
|
||
assert.equal(iEff(mk({ importance: 6, peak: 40, bonus: 1 })), 5)
|
||
assert.equal(iEff(mk({ importance: 10, peak: 100, bonus: 1 })), 5)
|
||
assert.equal(iEff(mk({ importance: 1 })), 1)
|
||
})
|
||
|
||
test('F = 0.5^(Δseq/200),原典固定 1,被檢索到時至少回到 0.8', () => {
|
||
assert.equal(freshness(mk(), 0), 1)
|
||
assert.equal(freshness(mk(), 200), 0.5)
|
||
assert.equal(freshness(mk({ canon: true }), 1000), 1)
|
||
assert.ok(freshness(mk(), 600) < 0.2)
|
||
assert.equal(freshness(mk({ hitSeq: 600 }), 600), 0.8)
|
||
assert.ok(freshness(mk({ hitSeq: 600 }), 800) < 0.8)
|
||
})
|
||
|
||
test('A 隨命中次數成長且有上限;W 為三者相乘', () => {
|
||
assert.equal(access(mk()), 1)
|
||
assert.ok(access(mk({ accessCount: 3 })) > 1)
|
||
assert.equal(access(mk({ accessCount: 5 })), access(mk({ accessCount: 50 })))
|
||
const m = mk({ importance: 6, peak: 40, accessCount: 5 })
|
||
assert.equal(weight(m, 200, T0), 4 * 0.5 * 1.5)
|
||
})
|
||
|
||
test('檢索實際用 W 排序:峰值高、較新者勝出', async () => {
|
||
const s = setup()
|
||
await s.mem.add({ layer: 'episodic', text: '聊到旅行', importance: 5, source: 'u' })
|
||
const peak = await s.mem.add({ layer: 'episodic', text: '聊到旅行', importance: 5, source: 'u', peak: 80 })
|
||
assert.equal((await s.mem.recall('旅行'))[0]!.id, peak.id)
|
||
})
|
||
})
|
||
|
||
describe('反覆思量', () => {
|
||
test('未解負向事件每日 +20%,上限 3 倍;已解或非負向不加', () => {
|
||
const neg = mk({ negative: true })
|
||
assert.equal(rumination(neg, T0), 1)
|
||
assert.ok(Math.abs(rumination(neg, T0 + 2 * DAY) - 1.4) < 1e-9)
|
||
assert.equal(rumination(neg, T0 + 30 * DAY), 3)
|
||
assert.equal(rumination({ ...neg, resolvedAt: T0 + DAY }, T0 + 5 * DAY), 1)
|
||
assert.equal(rumination(mk(), T0 + 5 * DAY), 1)
|
||
})
|
||
|
||
test('resolve 後不再被核心記憶列為未解', async () => {
|
||
const s = setup()
|
||
const m = await s.mem.add({ layer: 'emotional', text: '被罵了', importance: 6, source: 'u', negative: true })
|
||
assert.equal((await s.mem.core()).unresolved[0]!.id, m.id)
|
||
assert.equal(await s.mem.resolve(), 1)
|
||
assert.equal((await s.mem.core()).unresolved.length, 0)
|
||
assert.equal(await s.mem.resolve(), 0)
|
||
})
|
||
|
||
test('類別加成:忌妒或想念累積速度 ≥ 0.65 才 +1', () => {
|
||
assert.equal(categoryBonus('jealous', { jealous: 65 }), 1)
|
||
assert.equal(categoryBonus('jealous', { jealous: 64 }), 0)
|
||
assert.equal(categoryBonus('reunion', { miss: 80 }), 1)
|
||
assert.equal(categoryBonus('reunion', {}), 0)
|
||
})
|
||
})
|
||
|
||
describe('衰減與封存(不刪除)', () => {
|
||
test('暫時記憶滿 7 天封存,長期與鎖定記憶永不封存', () => {
|
||
assert.equal(shouldArchive(mk({ kind: 'temp' }), T0 + 6 * DAY), false)
|
||
assert.equal(shouldArchive(mk({ kind: 'temp' }), T0 + 7 * DAY), true)
|
||
assert.equal(shouldArchive(mk({ kind: 'long' }), T0 + 999 * DAY), false)
|
||
assert.equal(shouldArchive(mk({ kind: 'locked' }), T0 + 999 * DAY), false)
|
||
})
|
||
|
||
test('可升級記憶 30 天降權、90 天未檢索封存', () => {
|
||
const m = mk({ kind: 'upgradable' })
|
||
assert.equal(weight(m, 0, T0 + 29 * DAY), weight(m, 0, T0))
|
||
assert.equal(weight(m, 0, T0 + 30 * DAY), weight(m, 0, T0) / 2)
|
||
assert.equal(shouldArchive(m, T0 + 89 * DAY), false)
|
||
assert.equal(shouldArchive(m, T0 + 90 * DAY), true)
|
||
assert.equal(shouldArchive({ ...m, lastAccess: T0 + 80 * DAY }, T0 + 120 * DAY), false)
|
||
})
|
||
|
||
test('鞏固時封存過期記憶但不刪除,封存的不再被檢索', async () => {
|
||
const s = setup()
|
||
const trivial = await s.mem.add({ layer: 'episodic', text: '隨口聊天', importance: 1, source: 'u' })
|
||
const key = await s.mem.add({ layer: 'semantic', text: '生日是三月', importance: 9, source: 'u', kind: 'long' })
|
||
s.advance(10 * DAY)
|
||
const r = await s.mem.consolidate({ force: true })
|
||
assert.equal(r.archived, 1)
|
||
const all = await s.mem.all()
|
||
assert.equal(all.find(m => m.id === trivial.id)!.status, 'archived')
|
||
assert.equal(all.find(m => m.id === key.id)!.status, 'active')
|
||
assert.equal((await s.mem.recall('隨口聊天')).some(m => m.id === trivial.id), false)
|
||
})
|
||
|
||
test('場景結束時暫時記憶封存,其餘保留', async () => {
|
||
const s = setup()
|
||
const t = await s.mem.add({ layer: 'episodic', text: '剛剛的閒聊', importance: 2, source: 'u' })
|
||
const k = await s.mem.add({ layer: 'semantic', text: '喜歡貓', importance: 6, source: 'u' })
|
||
assert.equal(await s.mem.endScene(), 1)
|
||
const all = await s.mem.all()
|
||
assert.equal(all.find(m => m.id === t.id)!.status, 'archived')
|
||
assert.equal(all.find(m => m.id === k.id)!.status, 'active')
|
||
})
|
||
|
||
test('檢索到 3 次的可升級記憶升為長期,之後不再衰減', async () => {
|
||
const s = setup()
|
||
const m = await s.mem.add({ layer: 'semantic', text: '常提的咖啡', importance: 5, source: 'u' })
|
||
for (let i = 0; i < 3; i++) await s.mem.recall('咖啡')
|
||
assert.equal((await s.mem.all()).find(x => x.id === m.id)!.kind, 'long')
|
||
s.advance(400 * DAY)
|
||
await s.mem.consolidate({ force: true })
|
||
assert.equal((await s.mem.all()).find(x => x.id === m.id)!.status, 'active')
|
||
})
|
||
|
||
test('舊存檔(沒有 kind、status)讀入後自動補欄位', async () => {
|
||
const s = setup()
|
||
await s.fs.write('mem.json', JSON.stringify({ items: [{ id: 1, layer: 'semantic', text: '舊記憶', importance: 5, source: 'u', createdAt: T0, lastAccess: T0, accessCount: 0 }], nextId: 2, pending: 0, lastConsolidated: 0 }))
|
||
const m = (await s.mem.all())[0]!
|
||
assert.equal(m.status, 'active')
|
||
assert.equal(m.kind, 'upgradable')
|
||
assert.equal((await s.mem.recall('舊記憶'))[0]!.id, 1)
|
||
})
|
||
})
|
||
|
||
describe('危險內容不固化', () => {
|
||
test('只留安全事件摘要,不留原文也不升級', async () => {
|
||
const s = setup()
|
||
assert.ok(isDanger('我想自殺'))
|
||
assert.equal(isDanger('今天天氣很好'), false)
|
||
const m = await s.mem.add({ layer: 'episodic', text: '我想自殺,方法是……', importance: 9, source: 'u', kind: 'long' })
|
||
assert.ok(!m.text.includes('自殺'))
|
||
assert.ok(m.text.includes('安全事件摘要'))
|
||
assert.equal(m.kind, 'temp')
|
||
for (let i = 0; i < 4; i++) await s.mem.recall('安全事件摘要')
|
||
assert.equal((await s.mem.all())[0]!.kind, 'temp')
|
||
})
|
||
|
||
test('危險原文不進入反思', async () => {
|
||
const s = setup()
|
||
await s.mem.add({ layer: 'episodic', text: '想自殺', importance: 9, source: 'u' })
|
||
const r = await s.mem.consolidate({ force: true })
|
||
assert.equal(r.reflection, undefined)
|
||
})
|
||
})
|
||
|
||
describe('開放迴路', () => {
|
||
test('她的提問 2 回合沒有回應就逾時', async () => {
|
||
const s = setup()
|
||
await s.mem.openLoop('你今天吃飯了嗎?')
|
||
await s.mem.recall('隨便')
|
||
assert.equal((await s.mem.loops())[0]!.status, 'open')
|
||
await s.mem.recall('隨便')
|
||
assert.equal((await s.mem.loops())[0]!.status, 'timeout')
|
||
})
|
||
|
||
test('有回應就收尾,不會逾時', async () => {
|
||
const s = setup()
|
||
await s.mem.openLoop('你今天吃飯了嗎?')
|
||
assert.equal(await s.mem.respond('吃飯了,吃了麵'), 1)
|
||
await s.mem.recall('隨便')
|
||
await s.mem.recall('隨便')
|
||
assert.equal((await s.mem.loops())[0]!.status, 'answered')
|
||
})
|
||
|
||
test('未收尾的對話列入每回合必帶;逾時後移出', async () => {
|
||
const s = setup()
|
||
await s.mem.openLoop('週末要不要一起去?')
|
||
assert.equal((await s.mem.core()).loops.length, 1)
|
||
await s.mem.recall('x')
|
||
await s.mem.recall('x')
|
||
assert.equal((await s.mem.core()).loops.length, 0)
|
||
})
|
||
|
||
test('取出回覆中最後一個問句', () => {
|
||
assert.equal(lastQuestion('好喔。你吃飯了嗎?'), '你吃飯了嗎?')
|
||
assert.equal(lastQuestion('沒有問題。'), null)
|
||
})
|
||
})
|
||
|
||
describe('檢索:關鍵字 + RRF + W + 心情', () => {
|
||
test('RRF 依 Σ 1/(60+rank) 融合多份排名', () => {
|
||
const f = rrf([[1, 2], [2, 1]])
|
||
assert.equal(f.get(1), 1 / 61 + 1 / 62)
|
||
assert.equal(f.get(2), 1 / 62 + 1 / 61)
|
||
assert.equal(rrf([[7]]).get(7), 1 / 61)
|
||
})
|
||
|
||
test('保留外部檢索器介面(語意相似度不做,但可注入)', async () => {
|
||
const s = setup()
|
||
const a = await s.mem.add({ layer: 'semantic', text: '喜歡貓咪', importance: 5, source: 'u' })
|
||
const b = await s.mem.add({ layer: 'semantic', text: '喜歡狗狗', importance: 5, source: 'u' })
|
||
const r = await s.mem.recall('毛孩', undefined, { extra: [() => [b.id, a.id]] })
|
||
assert.equal(r[0]!.id, b.id)
|
||
})
|
||
|
||
test('釋懷速度越慢,心情一致加權越大', async () => {
|
||
const mood = { p: 0.8, a: 0.4, d: 0.4 }
|
||
const run = async (recover: number) => {
|
||
const s = setup()
|
||
await s.mem.add({ layer: 'emotional', text: '慶祝', importance: 5, source: 'u', emotion: { p: -0.8, a: 0.5, d: -0.5 } })
|
||
await s.mem.add({ layer: 'emotional', text: '慶祝', importance: 5, source: 'u', emotion: mood, peak: 0 })
|
||
return (await s.mem.recall('慶祝', mood, { recover }))[0]!.id
|
||
}
|
||
assert.equal(await run(0), 2)
|
||
assert.equal(await run(1), 2)
|
||
})
|
||
|
||
test('沒有關鍵字命中時退回依 W 排序', async () => {
|
||
const s = setup()
|
||
await s.mem.add({ layer: 'semantic', text: '普通事', importance: 2, source: 'u' })
|
||
const big = await s.mem.add({ layer: 'semantic', text: '重要事', importance: 9, source: 'u' })
|
||
assert.equal((await s.mem.recall('完全不相干'))[0]!.id, big.id)
|
||
})
|
||
})
|
||
|
||
describe('鞏固與反思', () => {
|
||
test('累積重要度未達門檻不執行,達門檻才執行並歸零', async () => {
|
||
const s = setup()
|
||
await s.mem.add({ layer: 'episodic', text: '小事', importance: 5, source: 'u' })
|
||
assert.deepEqual(await s.mem.consolidate(), { ran: false, archived: 0 })
|
||
for (let i = 0; i < 5; i++) await s.mem.add({ layer: 'episodic', text: `事件${i}`, importance: 5, source: 'u' })
|
||
assert.ok((await s.mem.pending()) >= CONSOLIDATE_THRESHOLD)
|
||
const r = await s.mem.consolidate()
|
||
assert.equal(r.ran, true)
|
||
assert.equal(await s.mem.pending(), 0)
|
||
assert.equal(r.reflection?.layer, 'reflection')
|
||
})
|
||
|
||
test('手動 reflect(force)即使未達門檻也執行,並使用注入的摘要', async () => {
|
||
const s = setup()
|
||
await s.mem.add({ layer: 'episodic', text: '一起修好了建置', importance: 4, source: 'u' })
|
||
const r = await s.mem.consolidate({ force: true, summarize: items => `反思:${items.length} 件事` })
|
||
assert.equal(r.ran, true)
|
||
assert.equal(r.reflection?.text, '反思:1 件事')
|
||
assert.ok(r.reflection!.source.startsWith('reflect:'))
|
||
})
|
||
|
||
test('反思不重複涵蓋舊事件,也不遞迴反思反思', async () => {
|
||
const s = setup()
|
||
await s.mem.add({ layer: 'episodic', text: '甲', importance: 4, source: 'u' })
|
||
await s.mem.consolidate({ force: true })
|
||
s.advance(H)
|
||
await s.mem.add({ layer: 'episodic', text: '乙', importance: 4, source: 'u' })
|
||
const r = await s.mem.consolidate({ force: true, summarize: items => items.map(i => i.text).join('+') })
|
||
assert.equal(r.reflection?.text, '乙')
|
||
})
|
||
|
||
test('沒有新記憶時鞏固不產生空反思', async () => {
|
||
const s = setup()
|
||
const r = await s.mem.consolidate({ force: true })
|
||
assert.equal(r.reflection, undefined)
|
||
})
|
||
|
||
test('資料經檔案持久化,重建後可繼續', async () => {
|
||
const s = setup()
|
||
await s.mem.add({ layer: 'semantic', text: '喜歡貓', importance: 5, source: 'u' })
|
||
const again = createMemory(s.fs, s.now, 'mem.json')
|
||
assert.equal((await again.recall('貓'))[0]!.text, '喜歡貓')
|
||
})
|
||
})
|