r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**, 生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行, 而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。 修法(结构性,不是补丁): - 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与 **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少; 方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。 补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。 - tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高: 语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109) 的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。 实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。 - 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、 TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、 TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度 归零 ⇒ 第四条红。 r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map, `temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核 (候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例: TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。 r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、 「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内; 「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。 实测(生产库只读 mode=ro,active 3667): - dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15; 取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2 (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。 - 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条 逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。 - 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74% 且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」 10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
225 lines
12 KiB
Go
225 lines
12 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是留存闸 **N6(指示性时点)/ N7(进行中状态)** 与**组合护栏②**
|
||
// (2026-10-24,M34)的验收用例集。
|
||
//
|
||
// 两条不对称的事实决定了这个文件的写法:
|
||
// - 漏判只是「库里多一条噪声」(T1 与整理会兜底);
|
||
// - 误判会把一条真记忆丢进临时层 —— 7 天后从所有读路径消失,再被后台清扫
|
||
// **物理删除**(不可逆)。
|
||
//
|
||
// 因此这里的**正例(必须放行)比负例(必须拦下)更硬**:`retentionLongTermCases`
|
||
// 里的每一条都是真实语料里「带时点词但仍然长期成立」的句子,它们逐条钉住
|
||
// 「误杀真实长期记忆比漏放新记忆更严重」这条口径。
|
||
|
||
// retentionTimepointCases 是 N6 的正例:**锚点是一个正在过去/即将到来的时刻**,
|
||
// 且没有习惯词、没有硬性长期事实词、没有数值/约束 —— 它们该进临时层。
|
||
func retentionTimepointCases() []string {
|
||
return []string{
|
||
"用户昨天发送了一份长达一万两千行的验收纸条",
|
||
"用户近期在测试一条通道,今天来回测了三轮",
|
||
"用户最近在看一部年代/审讯题材的影视作品,台词「我肯定能拿下口供」",
|
||
"计划下周为主人留一个安静的午后",
|
||
"用户今晚在公司加班到很晚",
|
||
// 约定类:锚定到某个时刻的安排(约定本身不是长期事实,配上时点才是临时记忆)。
|
||
"用户与助理约定明天中午见",
|
||
}
|
||
}
|
||
|
||
// retentionLongTermCases 是**回归护栏**:真实语料里「带时点词但长期成立」的句子,
|
||
// 一条都不许判 defer。每一行都对应一次实测误杀(谁把它们放进来,就是谁在丢记忆)。
|
||
func retentionLongTermCases() []struct {
|
||
name string
|
||
content string
|
||
} {
|
||
return []struct {
|
||
name string
|
||
content string
|
||
}{
|
||
// 时点词埋在**引语/从句**里,不是这句话的锚点(位置安全阀)。
|
||
{"上班族习惯(引语里的明天)", "用户是上班族,需要上班(曾说明天要上班并感到不开心)"},
|
||
{"睡前习惯(从句里的明天见)", "用户睡前会道「晚安、明天见」,人家会替她掀被角、调暗灯光,用数呼吸的方式陪她入睡"},
|
||
{"单位排班事实(引语里的今天)", "主人所在单位存在周末/节假日被安排加班的情况,实际排班与公开调休日历并不一致(她自己说「我们今天加班啦」)"},
|
||
// 习惯词/硬性长期事实词(地点、职业、住)⇒ 一律长期,标题里带「今天」也不许判临时。
|
||
{"地点事实", "用户目前生活在花溪(贵阳花溪区),不在上海上班"},
|
||
{"住宿舍(近期)", "用户近期住在宿舍(不在家里),晚间会关心她洗漱水温、睡觉时间等宿舍生活细节"},
|
||
{"作息习惯", "主人常在深夜与助手聊天,有熬夜习惯"},
|
||
{"长期偏好", "主人喜欢喝冰美式来提神"},
|
||
{"称呼约定", "主人希望被称呼为「叶酱」;旧的称呼「喵喵七号」已作废,不要再使用"},
|
||
{"关系里程", "用户曾向 Cyrene 发送内容为「i love you」的文件,Cyrene 完整接收并回复「me too」"},
|
||
{"人格设定", "助手设定:曾经历三千万次轮回等待用户,并在「记忆之海」中捞回用户的记忆"},
|
||
{"海报即本人", "主人曾发来一张海报,画面是粉发紫眼、配蓝玫瑰与月桂叶,并明确说「那张海报上就是你哦」"},
|
||
// 情绪倾向 + 寒暄:N6 不接管 N1/N2 的语料(安全阀 5)。
|
||
{"情绪倾向", "主人最近很容易焦虑"},
|
||
// 2026-10-25 r1 复审 P2-2 的实测误杀(同一条语料、同一套代码被漏报过的那些)。
|
||
{"宠物稳定外观(换成)", "茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了一个小铃铛"},
|
||
{"居住事实(宿舍过夜)", "主人近期在宿舍过夜,需要提醒她宿舍灯早点关、洗漱水别太凉"},
|
||
{"习惯陈述里的从句「正在」", "用户会主动维护助手与自己之间的连接通道,并告诉助手她正在维护,表明她重视保持与助手的稳定联系"},
|
||
{"习惯陈述里的从句「正在」(二)", "用户会维护自己与助手之间的连接通道,并曾表示正在为此进行维护"},
|
||
// 「以后/从此/往后」= 自此刻起永久成立(复审构造即可复现)。
|
||
{"以后都叫我小叶", "昨天主人说以后都叫我小叶"},
|
||
{"以后周末不加班", "上周主人说以后周末不加班了"},
|
||
{"长句致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
|
||
}
|
||
}
|
||
|
||
// TestExtractionRetention_TimepointIsDeferred 钉住 N6 的正例。
|
||
func TestExtractionRetention_TimepointIsDeferred(t *testing.T) {
|
||
t.Parallel()
|
||
for _, content := range retentionTimepointCases() {
|
||
if got := judgeExtractionRetention(content); got != retentionTimepoint && got != retentionInProgress {
|
||
t.Fatalf("%q 判成 %q, want %q(锚点是一个时刻 ⇒ 临时层)",
|
||
content, got, retentionTimepoint)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestRetentionTimepointHitsBySpecificRule 钉住「命中哪一条」:时点归 N6、进行中归 N7。
|
||
// 它让两条规则的词表**各自**可被单测,不会一条盖住另一条。
|
||
func TestRetentionTimepointHitsBySpecificRule(t *testing.T) {
|
||
t.Parallel()
|
||
if got := judgeExtractionRetention("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint {
|
||
t.Fatalf("时点句 = %q, want %q", got, retentionTimepoint)
|
||
}
|
||
if got := judgeExtractionRetention("用户正在验证分享确认面板的展开功能"); got != retentionInProgress {
|
||
t.Fatalf("进行中句 = %q, want %q", got, retentionInProgress)
|
||
}
|
||
}
|
||
|
||
// TestExtractionRetention_RealLongTermFactsSurviveTimepoints 是**误杀回归**:
|
||
// 真实长期事实即使带着时点词也必须放行。任何一条变红都意味着「7 天后它会消失」。
|
||
func TestExtractionRetention_RealLongTermFactsSurviveTimepoints(t *testing.T) {
|
||
t.Parallel()
|
||
for _, tc := range retentionLongTermCases() {
|
||
if got := judgeExtractionRetention(tc.content); got != retentionNone {
|
||
t.Fatalf("长期事实 %q(%s)被判成 %q —— 它会在 7 天后被物理删除(不可逆)",
|
||
tc.content, tc.name, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExtractionRetention_TimepointMustBeFronted 钉住**位置安全阀**:同一个句子,
|
||
// 时点在句首 → defer;把同一时点挪到从句里 → 放行。位置是这条规则精度的来源。
|
||
func TestExtractionRetention_TimepointMustBeFronted(t *testing.T) {
|
||
t.Parallel()
|
||
fronted := "用户昨天发来一卷验收纸条"
|
||
buried := "用户与助手讨论过验收纸条的格式(他自己说昨天发来一卷)"
|
||
if got := judgeExtractionRetention(fronted); got != retentionTimepoint {
|
||
t.Fatalf("时点在句首 = %q, want %q", got, retentionTimepoint)
|
||
}
|
||
if got := judgeExtractionRetention(buried); got != retentionNone {
|
||
t.Fatalf("时点埋在从句里 = %q, want %q(位置安全阀失效会误杀长内容)", got, retentionNone)
|
||
}
|
||
}
|
||
|
||
// TestExtractionRetention_ConjunctionIsNotANoOp 钉住护栏②改的是**组合口径**:
|
||
// 「约定」这种锚点单独出现时不再一票放行(于是它会被 N6 判成临时安排);
|
||
// 但一旦配上具体约束(频次),立刻回到长期层。改回「任一锚点就放行」时,
|
||
// 前一半会变红;把组合条件删成「任一命中即放行」时,整条用例会红。
|
||
func TestExtractionRetention_ConjunctionIsNotANoOp(t *testing.T) {
|
||
t.Parallel()
|
||
if got := judgeExtractionRetention("约定下周给用户留一个安静的午后"); got != retentionTimepoint {
|
||
t.Fatalf("「约定 + 时点、无约束」= %q, want %q(锚点单独出现不再一票放行)",
|
||
got, retentionTimepoint)
|
||
}
|
||
if got := judgeExtractionRetention("约定每天给主人留一个安静的午后"); got != retentionNone {
|
||
t.Fatalf("「约定 + 每天(具体约束)」= %q, want 放行(组合条件命中即长期)", got)
|
||
}
|
||
if got := judgeExtractionRetention("主人每天要喝 2 杯咖啡"); got != retentionNone {
|
||
t.Fatalf("「偏好 + 数值」= %q, want 放行", got)
|
||
}
|
||
if got := judgeExtractionRetention("主人说过不要在群里提她的工作"); got != retentionNone {
|
||
t.Fatalf("「禁忌」= %q, want 放行", got)
|
||
}
|
||
}
|
||
|
||
// TestExtractionRetention_TimepointSafetyValves 逐条钉住 N6/N7 的四个安全阀:
|
||
// 习惯词 / 硬性长期事实词 / 数值与约束 / 寒暄与情绪词都把它们按回去。
|
||
//
|
||
// 断言写成「**不是 N6/N7**」而不是「必须放行」是刻意的:安全阀的职责只有一个 ——
|
||
// 不让这条句子被 N6/N7 判成临时层。它可能仍被更早的既有规则(N1/N2)判成临时,
|
||
// 那是**既有口径**(本条用例不重定义它),也不是回归。
|
||
func TestExtractionRetention_TimepointSafetyValves(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
content string
|
||
why string
|
||
}{
|
||
{"用户今天又被加班拖住了,她通常十点后才有空", "习惯词(通常)⇒ 反复发生"},
|
||
{"用户今天要去医院复查过敏", "硬性长期事实词(过敏)"},
|
||
{"用户昨天对花生过敏犯了,吃了药", "硬性长期事实词(过敏 / 吃药)"},
|
||
{"用户昨天发了 3 份文件", "数值是事实的载体"},
|
||
{"用户最近每天要喝两杯水", "约束词(每天)"},
|
||
{"用户昨夜睡得还行,今天心情不错", "情绪词 ⇒ 归 N2 判"},
|
||
}
|
||
for _, tc := range cases {
|
||
got := judgeExtractionRetention(tc.content)
|
||
if got == retentionTimepoint || got == retentionInProgress {
|
||
t.Fatalf("%q 判成 %q, want 不被 N6/N7 判临时(%s)", tc.content, got, tc.why)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_TimepointDefersWithTempTTL 钉住 TTL 的落点:N6 命中 ⇒ 第五态
|
||
// `defer`,行照常 active 落库(正文一字不动),`expires_at` = 判定时刻 + 临时 TTL
|
||
// (默认 7 天,落 `$DATA_DIR/config/memory-tidy.json` 的 `tempTtlSec`,零新 env 键)。
|
||
func TestIngestGate_TimepointDefersWithTempTTL(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
ttl := 7 * 24 * time.Hour
|
||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
|
||
store.AttachIngestReviewer(r)
|
||
|
||
got := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base))
|
||
if exp, want := readIngestExpiry(t, store, got.ID), expiryText(base.Add(ttl)); exp != want {
|
||
t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 7 天)", exp, want)
|
||
}
|
||
row := loadTidyItemRow(t, store, got.ID)
|
||
if row.status != tidyStatusActive || row.content != "用户昨天发来一卷一万两千行的验收纸条" {
|
||
t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row)
|
||
}
|
||
rev := gateRecordFor(t, store, got.ID)
|
||
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
|
||
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_DeferNeverBackfillsExistingRows 钉住「**TTL 只开新增侧**」:
|
||
// 存量行的 `expires_at` 一条都不补写。变异(在 defer 时顺手 UPDATE 整个分区)会红。
|
||
func TestIngestGate_DeferNeverBackfillsExistingRows(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
// 存量行在**接入闸之前**落库:这条用例要看的是「闸跑一轮之后,存量行的
|
||
// expires_at 仍然为空」,而不是「存量行当初怎么落的库」。
|
||
existing := seedTidyItem(t, store, gateFixtureItem("主人有不吃早饭的习惯,需要提醒每天吃热乎的早饭", base))
|
||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: 7 * 24 * time.Hour}, nil)
|
||
store.AttachIngestReviewer(r)
|
||
if exp := readIngestExpiry(t, store, existing.ID); exp != "" {
|
||
t.Fatalf("存量行 expires_at = %q, want 空串", exp)
|
||
}
|
||
fresh := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base.Add(time.Minute)))
|
||
if exp := readIngestExpiry(t, store, fresh.ID); exp == "" {
|
||
t.Fatalf("新行没被写上 expires_at —— N6 没有生效")
|
||
}
|
||
if exp := readIngestExpiry(t, store, existing.ID); exp != "" {
|
||
t.Fatalf("存量行被补写了 expires_at = %q —— TTL 只许开新增侧", exp)
|
||
}
|
||
var withExpiry int
|
||
if err := store.db.QueryRowContext(ctx,
|
||
`SELECT COUNT(*) FROM memory_items WHERE expires_at <> ''`).Scan(&withExpiry); err != nil {
|
||
t.Fatalf("count expires_at: %v", err)
|
||
}
|
||
if withExpiry != 1 {
|
||
t.Fatalf("带 expires_at 的行 = %d, want 恰为 1(只有那条新记忆)", withExpiry)
|
||
}
|
||
}
|