feat: companion 記憶權重、反覆思量與檢索
- 權重 W = I_eff × F × A,暫時/可升級/長期記憶的衰減與封存,任何記憶不刪除 - 未解負向事件反覆思量、開放迴路逾時、危險內容不固化 - 檢索改為關鍵字 + RRF × W + 心情一致加權,保留外部檢索器介面 - 版本 0.0.4 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,33 @@ import { describe, test } from 'claude-code/testing'
|
||||
import assert from './assert'
|
||||
|
||||
import { memoryFs } from './io'
|
||||
import { CONSOLIDATE_THRESHOLD, FORGET_BELOW, TOP_K, createMemory, redact, retention, stability } from './memory'
|
||||
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()
|
||||
@@ -24,12 +46,8 @@ describe('記憶檢索', () => {
|
||||
})
|
||||
|
||||
test('較新、較重要者在其他條件相同時勝出', async () => {
|
||||
const s = setup()
|
||||
const old = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
|
||||
s.advance(100 * H)
|
||||
const fresh = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
|
||||
assert.equal((await s.mem.recall('部署'))[0]!.id, fresh.id)
|
||||
assert.notEqual(old.id, fresh.id)
|
||||
// 回合數相隔越久新鮮度越低;檢索會讓被取回的記憶回到 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' })
|
||||
@@ -60,39 +78,215 @@ describe('記憶檢索', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('遺忘曲線', () => {
|
||||
test('保留率隨時間下降,重要度與取用次數使其變慢', () => {
|
||||
const base = { id: 1, layer: 'episodic' as const, text: '', source: '', createdAt: T0, lastAccess: T0 }
|
||||
const weak = { ...base, importance: 2, accessCount: 0 }
|
||||
const strong = { ...base, importance: 8, accessCount: 0 }
|
||||
const used = { ...weak, accessCount: 5 }
|
||||
assert.ok(retention(weak, T0 + 24 * H) < retention(weak, T0))
|
||||
assert.ok(retention(strong, T0 + 100 * H) > retention(weak, T0 + 100 * H))
|
||||
assert.ok(retention(used, T0 + 100 * H) > retention(weak, T0 + 100 * H))
|
||||
assert.ok(stability(strong) > stability(weak))
|
||||
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('鞏固時淘汰低保留率的記憶,保留重要與常用的', async () => {
|
||||
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' })
|
||||
s.advance(10 * 24 * H)
|
||||
assert.ok(retention(trivial, s.now()) < FORGET_BELOW)
|
||||
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.forgotten, 1)
|
||||
const ids = (await s.mem.all()).map(m => m.id)
|
||||
assert.ok(!ids.includes(trivial.id))
|
||||
assert.ok(ids.includes(key.id))
|
||||
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 () => {
|
||||
test('場景結束時暫時記憶封存,其餘保留', async () => {
|
||||
const s = setup()
|
||||
const m = await s.mem.add({ layer: 'episodic', text: '常提起的事', importance: 2, source: 'u' })
|
||||
s.advance(40 * H)
|
||||
await s.mem.recall('常提起')
|
||||
s.advance(40 * H)
|
||||
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.ok((await s.mem.all()).some(x => x.id === m.id))
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,7 +294,7 @@ 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, forgotten: 0 })
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user