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:
2026-10-14 09:01:55 +00:00
co-authored by Claude Sonnet 5.5
parent 46f58a3ff8
commit 6f7b4d91ea
7 changed files with 599 additions and 80 deletions
@@ -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()