refactor(memory): M45 r1 复审返工 —— temporal 位只放开 N6 位置判据
按 r1 复审裁定(p1-3items / fix-then-merge)重做三处: P1-1/P1-2:删掉自造的六条语义安全阀(temporalSanityVeto)与约定词表 (retentionStatesAgreement)。理由被复审用两条生产行否证:它们既把该救的行 杀了(「…睡得比平时早」被习惯词、「…以后不用再惦记」被永久化标记),又挡不住 同义异形的约定(说定 / 约了)。现在 `one_off` 的全部作用是**放开 N6 的位置判据**: judgeExtractionRetention 新增 temporal 入参 → judgeRetentionTransient → judgeRetentionTimepointWide(位置内命中仍返 n6_timepoint,只有靠放开位置才命中 才返新增的 n6_timepoint_wide,供观测区分)。N1~N5、N7、护栏①②、N6 的 1~7 条 语义安全阀一字不动;durable / recurring 仍直接判 long(fail-safe 方向)。 P1-3:上界口径改为「时点词存在 ∧ 语义安全阀全过 ∧ 当前 long」——不依赖任何 标注,可逐条列出。实测(生产库只读副本,快照 2026-10-26):最近 500 条 active 能翻 13 条、**逐条都是「约定 + 今天没走成」**;全量 3694 条能翻 26 条(13 条 约定 + 13 条状态行)。旧的「句首时点词」口径是同义反复,已删。 已知代价(写进 temporal.go 头注与 TestTemporalOneOffWidenedPositionTouchesAgreements, 不藏在词表后面):位置窗口今天正是那批约定行的唯一保护,放开后只要模型标 one_off 它们就会判 temp;而复审点名的两条目标行本片救不了(被语义阀挡,与位置 无关)。因此这一位在当前语料上**没有可测收益**,是否保留是产品裁决。 P2-1:store.go 503 → 497 行,拆出 store_temporal.go(哨兵错误 + 字段校验 + 列值编码)。 P2-2:temporal.go 头注改准 —— `''` 是「0027 之前的历史行 + 没做时间性判断的 写入路径(ManualExtractor / 网关手写 / 整理回填,它们由调用方显式给档位)」。 P2-3:报告补语境(retention_tier 非空仅 26 行、temp 1 行、temporal 列当时还不 存在 ⇒ 重放结论对新写入没有预测力)。 测试:验收点 ①~⑤ 按新语义重写;新增「位置放开可打红」的变异靶子与「N7 位置 不放宽」「无时点词时这一位不做事」「两条目标行救不了」「约定行会翻(已知风险)」 五条边界断言。变异实测:unlocksTimepointPosition 改恒 false ⇒ 三条用例红。 回归:go build ./... + go vet ./... 全绿;memory/db 包测试与 memory -race 全绿。
This commit is contained in:
1 parent
91101dedfb
commit
3b9ae3f918
9 files changed
+412
-370
No files matched your search
@@ -9,30 +9,25 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集,逐条对应
|
||||
// 任务书的验收点:
|
||||
// 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集。
|
||||
//
|
||||
// ① `one_off` 的夹具语料 ⇒ 全链落库 `(temp, 非空 expires_at)`;
|
||||
// ② `durable` / `recurring` ⇒ `(long, 空)`;
|
||||
// ③ `unknown` 且内容像一次性 ⇒ **仍按旧规则**(本条不含新的猜测:命中的规则码
|
||||
// 必须是 N* 词项规则的码,绝不是时间性位的码);
|
||||
// ④ 模型返回非法值 / 空值 ⇒ 归 `unknown`,且**不报错**、不拖累同批候选;
|
||||
// ⑤ 变异:把 temporalVerdict 的 one_off 支路摘掉 ⇒ ① 必红(下面三条 ① 的断言
|
||||
// 都以「档位是 temp」为判据,摘掉支路后它们全部退化成 long)。
|
||||
// 🔴 这一位的职责边界(r1 复审裁定,决定了下面每条断言的写法):
|
||||
//
|
||||
// 🔴 这份语料的一半(`one_off` 那三条)**刻意挑成旧规则的非命中形态**:它们在
|
||||
// `Temporal` 为空时判 long、带上 `one_off` 时判 temp —— 于是「这一位真的改了什么」
|
||||
// 可以被单独观察,不会与 N1~N7 的效果混在一起(M37 的实测缺口正是「分不清是谁判的」)。
|
||||
// 它**只接管 N6 的位置判据** —— 模型说「这是某一次发生的事」⇒ 时点词埋在句子中段
|
||||
// 也算锚点。N6 的**全部语义安全阀照旧**(习惯词 / 硬性长期事实词 / 具体约束 /
|
||||
// 永久化 / 状态改变 / 寒暄情绪),N1~N5、N7、护栏①② 一字不动。
|
||||
//
|
||||
// 因此「`one_off` ⇒ 一定 temp」**不是**本片的语义(那是任务书初版口径,r1 复审用
|
||||
// 生产语料推翻了:按它实现会当天删掉 21 条长期约定)。下面的用例逐条钉住的是
|
||||
// 「放开位置后能翻哪些行」与「翻不了哪些行」——后者同样是断言,因为它们是**已知的
|
||||
// 边界**,不是待修的缺陷。
|
||||
|
||||
// temporalOneOffCorpus 是一次性事件的夹具语料:长句陈述形态、无句首时点词、
|
||||
// **不含约定/承诺词**(含约定词的句子由 retentionStatesAgreement 挡回旧规则,
|
||||
// 见 TestTemporalOneOffDoesNotOverrideStatedAgreement)—— 正是 M37 复盘点名的
|
||||
// 「一次性事件被写成 30~80 字第三人称陈述」那种形态。
|
||||
func temporalOneOffCorpus() []string {
|
||||
// temporalWideTimepointCorpus 是**只有放开位置判据才会命中**的夹具语料:时点词都在
|
||||
// 12 字之后(或句中没有),语义安全阀全部不触发。
|
||||
func temporalWideTimepointCorpus() []string {
|
||||
return []string{
|
||||
"桌面上的文件整理到一半被别的事打断了",
|
||||
"傍晚出门走十分钟的散步这一回没走成",
|
||||
"那条链路断线是主人自己排查到根因的",
|
||||
"桌面上的文件整理到一半,今天下午才收尾",
|
||||
"主人把桌面上的文件整理到一半,昨天被别的事打断了",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,97 +43,170 @@ func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind {
|
||||
return TemporalKind(raw)
|
||||
}
|
||||
|
||||
// TestTemporalOneOffLandsTempWithExpiry 钉住验收点①(也是变异点⑤的靶子):
|
||||
// 时间性位 `one_off` ⇒ 全链落库 `(temp, 非空 expires_at)`,且这一位真的写进了
|
||||
// `temporal` 列(判定与留痕不分叉)。
|
||||
// TestTemporalOneOffWidensTimepointPosition 钉住本片**唯一**的判定效果(也是变异靶子):
|
||||
// 时点词在句中时,`temporal=one_off` ⇒ N6 照常命中(规则码 `n6_timepoint_wide`)⇒
|
||||
// 全链落库 `(temp, 非空 expires_at)`;而**同一段正文**在未分型时是 long。
|
||||
//
|
||||
// 「全链」= 与生产同一条写路径:`Store.Add`(唯一 INSERT 路径)→ resolveRetention
|
||||
// → planIngest → 一笔 INSERT。测试**不绕开** Add,也不用任何测试专用的旁路。
|
||||
func TestTemporalOneOffLandsTempWithExpiry(t *testing.T) {
|
||||
// 变异方向:把 unlocksTimepointPosition 改成恒 false(或把 temporal 参数丢掉)
|
||||
// ⇒ 本用例的三条断言全红。
|
||||
func TestTemporalOneOffWidensTimepointPosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
for _, content := range temporalOneOffCorpus() {
|
||||
for _, content := range temporalWideTimepointCorpus() {
|
||||
content := content
|
||||
t.Run(content, func(t *testing.T) {
|
||||
// 先证明这段正文**不含** N1~N7 的命中:否则本用例就分不清是规则还是
|
||||
// 时间性位判的 temp(M37 的缺口就是分不清)。
|
||||
// 前提:这段正文在**旧规则**下是 long(时点不在句首 12 字内)。
|
||||
bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(bare): %v", err)
|
||||
}
|
||||
if bare.Tier != RetentionLong {
|
||||
t.Fatalf("夹具语料 %q 在旧规则下就判成 %q(want long)—— 这条夹具证明不了时间性位的作用,换一条",
|
||||
content, bare.Tier)
|
||||
t.Fatalf("夹具 %q 在旧规则下就判成 %q(want long)—— 它证明不了位置判据被放开", content, bare.Tier)
|
||||
}
|
||||
|
||||
// 放开位置后:命中 N6,规则码是 **wide** 那一档(可区分「词判」与「模型放开位置」)。
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(one_off): %v", err)
|
||||
}
|
||||
if verdict.Tier != RetentionTemp || verdict.Rule != retentionTimepointWide {
|
||||
t.Fatalf("temporal=one_off 的 %q = %q/%q, want temp/%q",
|
||||
content, verdict.Tier, verdict.Rule, retentionTimepointWide)
|
||||
}
|
||||
// 全链落库:与生产同一条写路径(Store.Add 是唯一 INSERT 路径)。
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(one_off): %v", err)
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionTemp {
|
||||
t.Fatalf("%q with temporal=one_off 落库档位 = %q, want %q", content, tier, RetentionTemp)
|
||||
t.Fatalf("落库档位 = %q, want %q", tier, RetentionTemp)
|
||||
}
|
||||
if expires == "" {
|
||||
t.Fatalf("%q 判成 temp 却没写 expires_at(两列分叉)", content)
|
||||
t.Fatalf("判成 temp 却没写 expires_at(两列分叉)")
|
||||
}
|
||||
if got.ExpiresAt.IsZero() {
|
||||
t.Fatalf("%q 的 Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)", content)
|
||||
t.Fatalf("Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)")
|
||||
}
|
||||
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
|
||||
t.Fatalf("%q 的 temporal 列 = %q, want %q(判定与留痕不许分叉)", content, nat, TemporalOneOff)
|
||||
t.Fatalf("temporal 列 = %q, want %q(判定与留痕不许分叉)", nat, TemporalOneOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDefersThroughGate 钉住「判定接线」:闸开着时,`one_off` 判出的
|
||||
// temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),而不是另开一条路径。
|
||||
//
|
||||
// 本用例同时是「不要造第二个判定入口」的观测点:`judgeIngest` 只读档位,因此这里
|
||||
// 除了档位列以外不额外断言任何规则重判的痕迹 —— 重判会让
|
||||
// TestRetentionJudgementHasSingleRuleCallSite 先红。
|
||||
func TestTemporalOneOffDefersThroughGate(t *testing.T) {
|
||||
// TestTemporalOneOffKeepsFrontedTimepointRule 钉住「放开位置」不改变**已经命中**的
|
||||
// 那些行:时点在句首时,规则码仍是 `n6_timepoint`(不是 wide)—— 否则留痕里就分不清
|
||||
// 「词判的」与「模型放开位置后判的」,而那条观测线正是本片要交的东西。
|
||||
func TestTemporalOneOffKeepsFrontedTimepointRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
ttl := time.Hour
|
||||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
|
||||
store.AttachIngestReviewer(r)
|
||||
|
||||
item := habitItem(temporalOneOffCorpus()[0], base, 0.9)
|
||||
item := habitItem("用户昨天发来一卷验收纸条", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := store.Add(context.Background(), item)
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
rev := gateRecordFor(t, store, got.ID)
|
||||
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
|
||||
t.Fatalf("one_off 条目的留痕 = %+v, want defer + %q", rev, retentionLowValueReason)
|
||||
}
|
||||
if rev.RuleVer != IngestRuleVersion {
|
||||
t.Fatalf("留痕规则版本 = %q, want %q", rev.RuleVer, IngestRuleVersion)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionTemp {
|
||||
t.Fatalf("one_off 条目档位 = %q, want %q", tier, RetentionTemp)
|
||||
}
|
||||
// 到期时刻来自同一个判定与同一个时钟(闸注入的 now + 面板 TTL)。
|
||||
if want := expiryText(base.Add(ttl)); expires != want {
|
||||
t.Fatalf("expires_at = %q, want %q", expires, want)
|
||||
if verdict.Rule != retentionTimepoint {
|
||||
t.Fatalf("时点在句首时规则码 = %q, want %q(这一条不需要模型放开位置)",
|
||||
verdict.Rule, retentionTimepoint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalDurableAndRecurringStayLong 钉住验收点②:`durable` / `recurring` ⇒
|
||||
// `(long, 空)`,**并且覆盖规则闸的 temp 结论**。
|
||||
// TestTemporalOneOffWidenedPositionTouchesAgreements 把 r1 复审指出的**已知风险**
|
||||
// 写成断言(而不是留一个词表去掩盖它)。
|
||||
//
|
||||
// 语料故意取一条旧规则会判 temp 的正文(N6 指示性时点):「模型已经答了这不是
|
||||
// 一次性事件」时,规则不许把它改判成临时层 —— 方向是 fail-safe(多留一条),
|
||||
// 与「规则闸拦下的不许被 T1 推翻」并不矛盾:那一条防的是**放宽**(模型把规则判成
|
||||
// 临时的改成长期),方向相反。
|
||||
// 事实:N6 的位置窗口**今天正是**那批「约定 + 某一次没走成」行的唯一保护 —— 它们的
|
||||
// 时点词都埋在中段。放开位置之后,只要模型把这些行标成 `one_off`,它们就会判 temp、
|
||||
// 7 天后被删。2026-10-27 生产重放实测:最近 500 条里**能翻的 13 条逐条都是这种约定**
|
||||
// (全量 3694 条里 26 条,其中 13 条是约定)。
|
||||
//
|
||||
// 为什么不在这里加词表:r1 复审已经用两条反例否掉了词面否决 —— 它既挡不住同义异形
|
||||
// 的约定(`说定`/`约了`),又会误伤该救的行。因此**安全边界落在这里,而不是代码里**:
|
||||
// 这些行是否被删,取决于模型把它们标成 durable(prompt 的 durable 定义里就写着
|
||||
// 「约定」)还是 one_off。本用例的作用是让这条边界在 diff 里显形、可被复审追踪;
|
||||
// 是否接受它,是塔的裁决(见 M45 报告的决策矩阵)。
|
||||
func TestTemporalOneOffWidenedPositionTouchesAgreements(t *testing.T) {
|
||||
t.Parallel()
|
||||
const content = "和主人约了傍晚出门走十分钟,今天没走成,明天补"
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier != RetentionTemp {
|
||||
t.Fatalf("%q = %q —— 若这里变成 long,说明位置判据没有被放开(本片的全部作用消失);"+
|
||||
"若它一直是 temp,则是已知的误弃风险,见本用例头注", content, verdict.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffKeepsSemanticValves 钉住这一位**翻不了**的那两类(r1 复审点名的
|
||||
// 生产行)。它们不是缺陷,是职责边界:语义安全阀判的是「这句话不止是一个时刻」,
|
||||
// 与「时点词在第几个字」无关,因此时间性位不参与它们的判定。
|
||||
//
|
||||
// 如实记录(复审要求):**这两条正是 M37 说该救的行,而本片救不了它们** ——
|
||||
// 要救就得让模型推翻习惯词/永久化标记,那是另一件事(见报告)。
|
||||
func TestTemporalOneOffKeepsSemanticValves(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct{ content, blockedBy string }{
|
||||
{"昨晚主人喝了一杯热牛奶才睡下,睡得比平时早", "习惯词(平时)"},
|
||||
{"今天就终于把拖了一个月的 bug 修好了,以后不用再惦记", "永久化标记(以后)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.content, func(t *testing.T) {
|
||||
item := habitItem(tc.content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier == RetentionTemp {
|
||||
t.Fatalf("%q 被判成 temp —— 语义安全阀(%s)失效了;这一位只接管位置判据,不许动语义阀",
|
||||
tc.content, tc.blockedBy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffInProgressPositionUnchanged 钉住 N7 的位置判据**不**被放开:
|
||||
// r1 复审的裁定只覆盖 N6。「正在」出现在从句里仍然是习惯陈述(「…她正在维护…」),
|
||||
// 时间性位没有理由替它开火。
|
||||
func TestTemporalOneOffInProgressPositionUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
const content = "那件还没有结论的事,主人正在慢慢想"
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier == RetentionTemp {
|
||||
t.Fatalf("%q 被判成 temp —— N7 的位置判据不该被时间性位放开(裁定只覆盖 N6)", content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffWithoutTimepointIsInert 钉住「这一位不是无条件 temp」:正文里
|
||||
// 一个时点词都没有时,放开位置也无事可做 —— 结论仍是旧的 long。
|
||||
func TestTemporalOneOffWithoutTimepointIsInert(t *testing.T) {
|
||||
t.Parallel()
|
||||
const content = "桌面上的文件整理到一半被别的事打断了"
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier != RetentionLong {
|
||||
t.Fatalf("%q 没有时点词却判成 %q —— 这一位只放开 N6 的位置,不新造判据", content, verdict.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalDurableAndRecurringStayLong 钉住任务书验收点②:`durable` / `recurring`
|
||||
// ⇒ `(long, 空)`,**并且覆盖规则闸的 temp 结论**(模型已答「不是一次性事件」,
|
||||
// 方向是 fail-safe:多留)。
|
||||
func TestTemporalDurableAndRecurringStayLong(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -175,18 +243,17 @@ func TestTemporalDurableAndRecurringStayLong(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalUnknownFallsThroughToRules 钉住验收点③,也是最容易做偏的一条:
|
||||
// `unknown`(以及未分型的空串)**不许自己判 temp** —— 它必须原样交给 N1~N7,
|
||||
// 判据只有旧规则那一份。
|
||||
// TestTemporalUnknownAndUnsetFollowOldRules 钉住任务书验收点③:`unknown`(以及未分型
|
||||
// 的空串)**不许自己判 temp** —— 它们必须原样交给 N1~N7,判据只有旧规则那一份。
|
||||
//
|
||||
// 三条断言方向不同,缺一条都测不出「方向反了」:
|
||||
//
|
||||
// 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:命中的
|
||||
// 规则码必须是 `n6_timepoint`,**绝不能**是时间性位的 `temporal_one_off` ——
|
||||
// 后者出现就说明有人把「看起来像今日事」变成了新的判据;
|
||||
// 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:规则码必须
|
||||
// 是 `n6_timepoint`,**绝不能**是 `n6_timepoint_wide`(后者出现就说明位置判据被
|
||||
// 一个「拿不准」的答案放开了);
|
||||
// 2. `unknown` + 旧规则非命中的正文 ⇒ long(不许因为看着像一次性就判 temp);
|
||||
// 3. 未分型(空串)与 `unknown` 走同一条路(历史行的行为逐字不变)。
|
||||
func TestTemporalUnknownFallsThroughToRules(t *testing.T) {
|
||||
func TestTemporalUnknownAndUnsetFollowOldRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -197,18 +264,15 @@ func TestTemporalUnknownFallsThroughToRules(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier != RetentionTemp {
|
||||
t.Fatalf("unknown + 「今天下雨了」= %q, want %q(旧规则 N6 的结论)", verdict.Tier, RetentionTemp)
|
||||
}
|
||||
if verdict.Rule != retentionTimepoint {
|
||||
t.Fatalf("命中的规则 = %q, want %q —— 若出现 %q 说明 unknown 被当成了「一次性」的新判据",
|
||||
verdict.Rule, retentionTimepoint, retentionTemporalTempRule)
|
||||
if verdict.Tier != RetentionTemp || verdict.Rule != retentionTimepoint {
|
||||
t.Fatalf("unknown + 「今天下雨了」= %q/%q, want temp/%q(旧规则 N6 的结论,不是 wide)",
|
||||
verdict.Tier, verdict.Rule, retentionTimepoint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown_旧规则非命中", func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9)
|
||||
item := habitItem(temporalWideTimepointCorpus()[0], tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalUnknown
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
@@ -216,7 +280,7 @@ func TestTemporalUnknownFallsThroughToRules(t *testing.T) {
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionLong || expires != "" {
|
||||
t.Fatalf("unknown 且旧规则不命中时落库 = (%q, %q), want (%q, 空) —— 「看着像今日事」不许判 temp",
|
||||
t.Fatalf("unknown 且旧规则不命中时落库 = (%q, %q), want (%q, 空) —— 拿不准就必须留",
|
||||
tier, expires, RetentionLong)
|
||||
}
|
||||
})
|
||||
@@ -237,32 +301,13 @@ func TestTemporalUnknownFallsThroughToRules(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDoesNotOverrideExplicitRemember 钉住交叉规则:显式「记住」的
|
||||
// 一票否决(extract_retention.go 护栏①)对时间性位**同样有效**。
|
||||
//
|
||||
// 没有这条,模型把「记住我明天三点开会」标成 one_off 就会让主人在 7 天后丢掉自己
|
||||
// 明确要求记的东西 —— 那是 fail-safe 方向的反面,且不可逆。
|
||||
func TestTemporalOneOffDoesNotOverrideExplicitRemember(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := habitItem("记住:明天下午三点开会,别迟到", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier != RetentionLong {
|
||||
t.Fatalf("显式「记住」+ one_off = %q, want %q(护栏① 一票否决对元数据位同样有效)",
|
||||
verdict.Tier, RetentionLong)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDoesNotTouchRestrictedSource 钉住与受限来源的优先级:群聊 /
|
||||
// 陌生人的行**不分型**(空串),因此时间性位不许把它们判成 temp —— 那会造出
|
||||
// 「对读面隐藏、却永远不会被后台清扫删除」的行(expire.go 跳过非 owner 来源)。
|
||||
func TestTemporalOneOffDoesNotTouchRestrictedSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9)
|
||||
item := habitItem("今天下雨了", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
item.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1")
|
||||
got, err := store.Add(context.Background(), item)
|
||||
@@ -275,84 +320,44 @@ func TestTemporalOneOffDoesNotTouchRestrictedSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDoesNotOverrideStatedAgreement 钉住 `one_off` 支路的第二条
|
||||
// 例外,也是本片最要紧的一条 fail-safe:**正文在陈述一份约定时,`one_off` 不许把它
|
||||
// 推进临时层**。
|
||||
//
|
||||
// 用例内容逐字取自 M37 复审的量化证据(把 N6 时点窗口放宽到 24 字会多命中 11 条,
|
||||
// 逐条都是「同一条长期约定的重复陈述」):
|
||||
//
|
||||
// 「一起晒太阳」暂时搁置 ×5 → 今天是「约定 + 暂缓」
|
||||
// 「傍晚出门走十分钟…今天没走成明天补上」×6 → 今天是「约定 + 某一次没走成」
|
||||
//
|
||||
// 这两种句子**确实在描述某一次**,因此模型很容易标成 one_off;若代码照单全收,
|
||||
// 7 天后被删掉的就是约定本身 —— M37 的原话:「放宽阈值会在 7 天后把约定本身删掉,
|
||||
// 违背 fail-safe」。
|
||||
func TestTemporalOneOffDoesNotOverrideStatedAgreement(t *testing.T) {
|
||||
// TestTemporalBitDrivesDeferThroughGate 钉住判定接线:闸开着时,位置被放开后判出的
|
||||
// temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),不是另开一条路径。
|
||||
func TestTemporalBitDrivesDeferThroughGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
agreements := []string{
|
||||
"两人之间有一个「晒太阳」的约定,目前暂缓,等主人想起来再提",
|
||||
"和主人有「晚饭后出门走十分钟」的约定;今天没走成,算欠着,明天补上",
|
||||
"和叶酱约好每天出门走十分钟;今天太阳先落山没走成,这十分钟欠着",
|
||||
}
|
||||
for _, content := range agreements {
|
||||
content := content
|
||||
t.Run(content, func(t *testing.T) {
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier == RetentionTemp {
|
||||
t.Fatalf("%q 因 temporal=one_off 被判成 temp —— 这是长期约定,7 天后会被删掉(fail-safe 反了)", content)
|
||||
}
|
||||
if !retentionStatesAgreement(content) {
|
||||
t.Fatalf("retentionStatesAgreement(%q) = false —— 例外没认出这条约定", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
ttl := time.Hour
|
||||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
|
||||
store.AttachIngestReviewer(r)
|
||||
|
||||
// TestTemporalOneOffDoesNotOverrideDurableShape 钉住 `one_off` 支路的语义安全阀
|
||||
// (temporalSanityVeto 的后四条):正文自己在说「习惯 / 长期事实 / 自此刻起永久 /
|
||||
// 状态已改变」时,模型标的一次性**不给结论**,落回旧规则。
|
||||
//
|
||||
// 语料全部取自 2026-10-27 对生产库 active 集合的离线重放里**真实出现过**的形态
|
||||
// (它们是「上界候选」的主要来源:被时点词捎带的状态事实)。判成 temp 的后果是
|
||||
// 7 天后被删,因此这些形态必须留住。
|
||||
func TestTemporalOneOffDoesNotOverrideDurableShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct{ content, why string }{
|
||||
{"主人有每天吃早餐的习惯,几乎不会省略早餐。", "习惯词(反复发生,与一次性互斥)"},
|
||||
{"用户近期住宿舍(昨天住宿舍,可能今晚还在),需要关心住宿情况。", "硬性长期事实词(居住)"},
|
||||
{"茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了个小铃铛。", "状态改变(新状态会持续)"},
|
||||
{"主人昨天说以后都叫我小叶", "永久化标记(自此刻起一直成立)"},
|
||||
item := habitItem(temporalWideTimepointCorpus()[0], base, 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := store.Add(context.Background(), item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.content, func(t *testing.T) {
|
||||
item := habitItem(tc.content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
if !temporalSanityVeto(tc.content) {
|
||||
t.Fatalf("temporalSanityVeto(%q) = false(%s)", tc.content, tc.why)
|
||||
}
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier == RetentionTemp {
|
||||
t.Fatalf("%q 因 temporal=one_off 被判成 temp —— %s,删掉是误弃", tc.content, tc.why)
|
||||
}
|
||||
})
|
||||
rev := gateRecordFor(t, store, got.ID)
|
||||
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
|
||||
t.Fatalf("one_off 放开位置后判 temp 的留痕 = %+v, want defer + %q", rev, retentionLowValueReason)
|
||||
}
|
||||
if rev.RuleVer != IngestRuleVersion {
|
||||
t.Fatalf("留痕规则版本 = %q, want %q", rev.RuleVer, IngestRuleVersion)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionTemp {
|
||||
t.Fatalf("档位 = %q, want %q", tier, RetentionTemp)
|
||||
}
|
||||
// 到期时刻来自同一个判定与同一个时钟(闸注入的 now + 面板 TTL)。
|
||||
if want := expiryText(base.Add(ttl)); expires != want {
|
||||
t.Fatalf("expires_at = %q, want %q", expires, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTemporal 钉住验收点④的归一化口径:**只认四个字面值**,其余
|
||||
// (空 / 中文 / 自造 / 拼写变体)一律 unknown。
|
||||
//
|
||||
// 不做同义词映射是刻意的:猜同义词的代价是单向的(猜成 one_off 就删记忆),
|
||||
// 而「拿不准一律 long」才是 fail-safe。
|
||||
// 不做同义词映射是刻意的:猜同义词的代价是单向的(猜成 one_off 就放开位置判据),
|
||||
// 而「拿不准一律留」才是 fail-safe。
|
||||
func TestNormalizeTemporal(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -395,6 +400,15 @@ func TestTemporalKindClosedSet(t *testing.T) {
|
||||
if TemporalUnknown.assigned() != true || TemporalUnset.assigned() != false {
|
||||
t.Fatal("assigned() 只应对非空串为真(「未分型」的唯一判据)")
|
||||
}
|
||||
// 只有 one_off 会放开 N6 的位置判据。
|
||||
for _, t2 := range []TemporalKind{TemporalUnset, TemporalUnknown, TemporalDurable, TemporalRecurring} {
|
||||
if t2.unlocksTimepointPosition() {
|
||||
t.Fatalf("%q 不该放开位置判据", t2)
|
||||
}
|
||||
}
|
||||
if !TemporalOneOff.unlocksTimepointPosition() {
|
||||
t.Fatal("one_off 必须放开 N6 的位置判据(本片的全部作用)")
|
||||
}
|
||||
// 非法值必须被写入路径拒掉(不是静默落库成脏列值)。
|
||||
item := habitItem("一条正文", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalKind("sometimes")
|
||||
|
||||
Reference in new issue
Block a user