refactor(memory): M45 裁决 D —— temporal 位撤出判定,只保留观测
按 r1 复审裁决 D:这一位先只做观测,`resolveRetention` 不许读它。理由(实测):
无条件 one_off ⇒ temp 会当天删 21 条长期约定;只放开 N6 位置判据能翻的 13 条
逐条是约定、而复审点名的三条目标行 0/3 救得了。在「模型真实标签分布」这份证据
缺位时不该做 7 天后物理删的不可逆决定。
撤接线(结论集逐字回到 M45 之前):
- retention.go:resolveRetention 不再读 item.Temporal;与之对比 dev base
(af61b8c)本文件**只有注释差异**,规则与判定入口逐字未变。
- extract_retention.go:judgeExtractionRetention 签名回退单参;删除
judgeRetentionTimepointWide 与 n6_timepoint_wide 规则码。与 dev base 对比
**同样只有注释差异** ⇒ 「结论集 = 不含 temporal 的那个集合」是结构性的,
不是靠调用点记得别传。
- 连带取消初版的 durable / recurring ⇒ long(零影响必须是双向的)。
保留观测面(一律未动):迁移 0027 的列、自举前滚、提取器的 temporal 产出、
prompt 枚举、闭集与 normalizeTemporal、store_temporal.go 的校验/编码。
另外把 prompt 里「标成 one_off 会让它进临时层、到期消失」这句**撤接线后已是假话**
改成中性标注说明 —— 否则会诱导模型避开 one_off,观测数据直接被污染。
测试:
- TestTemporalBitDoesNotAffectRetention —— 4 类语料 × 5 个取值,(档位,规则码)
与未分型逐字相同(核心正断言);
- TestTemporalOneOffDoesNotMakeAnythingTemporary / ...DoesNotRescueRuleHitRows
—— 零影响的双向点名;
- TestTemporalIsStillRecorded —— one_off 的约定行仍落 temporal='one_off' 而档位
long、expires 空(观测在、判定不动);
- TestTemporalDoesNotTouchExplicitTierOrRestrictedSource —— 人工入口与受限来源不变。
变异实测:把 `if item.Temporal == TemporalOneOff { return temp }` 插回
resolveRetention ⇒ 上述四条用例全红(FAIL 输出已贴给塔),恢复后全绿。
IngestRuleVersion 保持 d3.5,注释改准为「记录 temporal 信号,但不参与判定」
(留痕面变了:库里多了「模型当时标了什么」这一维,回退会让新旧留痕混在一起)。
P2 三条保留:store.go 500 行(拆 store_temporal.go)、temporal.go 头注('' 的口径
+ 两次被否的接线方案 + 接线前必须看的那两条统计 SQL)、报告语境段。
回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race
38.2s 全绿。
This commit is contained in:
1 parent
3b9ae3f918
commit
c7c0294a16
11 files changed
+280
-551
No files matched your search
@@ -4,30 +4,172 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集。
|
||||
//
|
||||
// 🔴 这一位的职责边界(r1 复审裁定,决定了下面每条断言的写法):
|
||||
// 🔴 当前契约(r1 复审裁决 D,2026-10-27):**这一位只观测,不参与判定。**
|
||||
// `resolveRetention` 不读它,判定的结论集与「没有这一位」逐字相同。因此本文件最重要
|
||||
// 的一条断言是**零影响**(TestTemporalBitDoesNotAffectRetention):同一段正文,
|
||||
// 无论 `temporal` 是 five 个取值里的哪一个,档位与规则码都必须完全相同。
|
||||
//
|
||||
// 它**只接管 N6 的位置判据** —— 模型说「这是某一次发生的事」⇒ 时点词埋在句子中段
|
||||
// 也算锚点。N6 的**全部语义安全阀照旧**(习惯词 / 硬性长期事实词 / 具体约束 /
|
||||
// 永久化 / 状态改变 / 寒暄情绪),N1~N5、N7、护栏①② 一字不动。
|
||||
//
|
||||
// 因此「`one_off` ⇒ 一定 temp」**不是**本片的语义(那是任务书初版口径,r1 复审用
|
||||
// 生产语料推翻了:按它实现会当天删掉 21 条长期约定)。下面的用例逐条钉住的是
|
||||
// 「放开位置后能翻哪些行」与「翻不了哪些行」——后者同样是断言,因为它们是**已知的
|
||||
// 边界**,不是待修的缺陷。
|
||||
// 变异靶子与之配套:谁把这一位重新接进 `resolveRetention`(例如先加一句
|
||||
// `if item.Temporal == TemporalOneOff { return temp }`),那条正断言必红 ——
|
||||
// 这不是「测试太严」,而是「接线前必须先有模型真实标签分布」这个裁决的守卫。
|
||||
|
||||
// temporalWideTimepointCorpus 是**只有放开位置判据才会命中**的夹具语料:时点词都在
|
||||
// 12 字之后(或句中没有),语义安全阀全部不触发。
|
||||
func temporalWideTimepointCorpus() []string {
|
||||
// temporalObservationCorpus 覆盖三类正文,每类都必须对 temporal 免疫:
|
||||
//
|
||||
// ① 旧规则判 long 的行(接线的「收益」候选);
|
||||
// ② 旧规则判 temp 的行(`durable` 也不能把它们救回来 —— 零影响是双向的);
|
||||
// ③ 受限来源 / 显式档位这类「更早的分支」(它们更不该被这一位碰到)。
|
||||
func temporalObservationCorpus() []string {
|
||||
return []string{
|
||||
"桌面上的文件整理到一半,今天下午才收尾",
|
||||
"主人把桌面上的文件整理到一半,昨天被别的事打断了",
|
||||
"和主人有一个出门走十分钟的约定;今天没走成,明天要补上。",
|
||||
"主人有每天吃早餐的习惯,几乎不会省略早餐。",
|
||||
"今天下雨了",
|
||||
"谢谢啦",
|
||||
}
|
||||
}
|
||||
|
||||
// temporalAllKinds 是「业务上可能出现的全部取值」+ 未分型。
|
||||
func temporalAllKinds() []TemporalKind {
|
||||
return []TemporalKind{
|
||||
TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown,
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalBitDoesNotAffectRetention 是本文件的核心断言:**temporal 对留存判定零影响**
|
||||
// ——对同一段正文,五个取值的 `(档位, 规则码)` 必须逐字相同(且都以未分型为基准)。
|
||||
func TestTemporalBitDoesNotAffectRetention(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, content := range temporalObservationCorpus() {
|
||||
content := content
|
||||
t.Run(content, func(t *testing.T) {
|
||||
base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(base): %v", err)
|
||||
}
|
||||
for _, temporal := range temporalAllKinds() {
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = temporal
|
||||
got, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(temporal=%q): %v", temporal, err)
|
||||
}
|
||||
if got != base {
|
||||
t.Fatalf("%q with temporal=%q 判成 %+v, want %+v(这一位必须零影响;"+
|
||||
"接线前必须先拿到模型真实标签分布,见 temporal.go 头注)",
|
||||
content, temporal, got, base)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDoesNotMakeAnythingTemporary 是上一条的正向版本,单独成例是为了
|
||||
// 让「接线会怎样」在失败信息里一眼可见:它只喂 `one_off`,并点名断言「旧规则判 long
|
||||
// 的行仍然 long」。
|
||||
func TestTemporalOneOffDoesNotMakeAnythingTemporary(t *testing.T) {
|
||||
t.Parallel()
|
||||
const content = "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。"
|
||||
base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(base): %v", err)
|
||||
}
|
||||
if base.Tier != RetentionLong {
|
||||
t.Fatalf("前提不成立:%q 旧规则判 %q(want long)", content, base.Tier)
|
||||
}
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(one_off): %v", err)
|
||||
}
|
||||
if got.Tier != RetentionLong || got.Rule != retentionNone {
|
||||
t.Fatalf("temporal=one_off 把 %q 判成 %+v —— 这一位不得参与判定(裁决 D)", content, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalIsStillRecorded 钉住「撤接线不等于撤观测」:`temporal` 仍随同一笔
|
||||
// INSERT 落库(迁移 0027 的列),而档位/有效期保持旧规则的结论 —— 这正是 D 要的
|
||||
// 数据来源:全库按 temporal 分组统计、抽样看 one_off 到底长什么样。
|
||||
func TestTemporalIsStillRecorded(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
const content = "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。"
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
|
||||
t.Fatalf("temporal 列 = %q, want %q(观测面必须保留)", nat, TemporalOneOff)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionLong || expires != "" {
|
||||
t.Fatalf("落库档位/时效 = (%q, %q), want (long, 空) —— 观测不得改变判定",
|
||||
tier, expires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalDoesNotRescueRuleHitRows 钉住零影响是**双向**的:`durable` 曾经(M45
|
||||
// 初版)能把规则判 temp 的行救回 long;裁决 D 之后这条也取消 —— 判定只由 N1~N7 决定。
|
||||
func TestTemporalDoesNotRescueRuleHitRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
const content = "今天下雨了"
|
||||
base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(base): %v", err)
|
||||
}
|
||||
if base.Tier != RetentionTemp {
|
||||
t.Fatalf("前提不成立:%q 旧规则判 %q(want temp)", content, base.Tier)
|
||||
}
|
||||
for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} {
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = temporal
|
||||
got, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(%q): %v", temporal, err)
|
||||
}
|
||||
if got != base {
|
||||
t.Fatalf("%q with temporal=%q 判成 %+v, want %+v(这一位不得推翻规则)",
|
||||
content, temporal, got, base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalDoesNotTouchExplicitTierOrRestrictedSource 钉住「更早的分支」不被这一位
|
||||
// 影响:显式档位(人工入口)与受限来源(群聊/陌生人)都排在第 1/3 步。
|
||||
func TestTemporalDoesNotTouchExplicitTierOrRestrictedSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
explicit := habitItem("谢谢啦", tidyTestTime(t), 0.9)
|
||||
explicit.RetentionTier = RetentionLong
|
||||
explicit.Temporal = TemporalOneOff
|
||||
got, err := store.Add(ctx, explicit)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(explicit long): %v", err)
|
||||
}
|
||||
if tier, _ := readRetentionColumns(t, store, got.ID); tier != RetentionLong {
|
||||
t.Fatalf("显式 long + one_off 落库 = %q, want long(调用方显式档位不许被推翻)", tier)
|
||||
}
|
||||
|
||||
restricted := habitItem("今天下雨了", tidyTestTime(t), 0.9)
|
||||
restricted.Temporal = TemporalOneOff
|
||||
restricted.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1")
|
||||
got, err = store.Add(ctx, restricted)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(restricted): %v", err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionTierUnset || expires != "" {
|
||||
t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,321 +185,11 @@ func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind {
|
||||
return TemporalKind(raw)
|
||||
}
|
||||
|
||||
// TestTemporalOneOffWidensTimepointPosition 钉住本片**唯一**的判定效果(也是变异靶子):
|
||||
// 时点词在句中时,`temporal=one_off` ⇒ N6 照常命中(规则码 `n6_timepoint_wide`)⇒
|
||||
// 全链落库 `(temp, 非空 expires_at)`;而**同一段正文**在未分型时是 long。
|
||||
// TestNormalizeTemporal 钉住归一化口径:**只认四个字面值**,其余(空 / 中文 / 自造 /
|
||||
// 拼写变体)一律 unknown。
|
||||
//
|
||||
// 变异方向:把 unlocksTimepointPosition 改成恒 false(或把 temporal 参数丢掉)
|
||||
// ⇒ 本用例的三条断言全红。
|
||||
func TestTemporalOneOffWidensTimepointPosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
for _, content := range temporalWideTimepointCorpus() {
|
||||
content := content
|
||||
t.Run(content, func(t *testing.T) {
|
||||
// 前提:这段正文在**旧规则**下是 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)
|
||||
}
|
||||
// 放开位置后:命中 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: %v", err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionTemp {
|
||||
t.Fatalf("落库档位 = %q, want %q", tier, RetentionTemp)
|
||||
}
|
||||
if expires == "" {
|
||||
t.Fatalf("判成 temp 却没写 expires_at(两列分叉)")
|
||||
}
|
||||
if got.ExpiresAt.IsZero() {
|
||||
t.Fatalf("Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)")
|
||||
}
|
||||
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
|
||||
t.Fatalf("temporal 列 = %q, want %q(判定与留痕不许分叉)", nat, TemporalOneOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffKeepsFrontedTimepointRule 钉住「放开位置」不改变**已经命中**的
|
||||
// 那些行:时点在句首时,规则码仍是 `n6_timepoint`(不是 wide)—— 否则留痕里就分不清
|
||||
// 「词判的」与「模型放开位置后判的」,而那条观测线正是本片要交的东西。
|
||||
func TestTemporalOneOffKeepsFrontedTimepointRule(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.Rule != retentionTimepoint {
|
||||
t.Fatalf("时点在句首时规则码 = %q, want %q(这一条不需要模型放开位置)",
|
||||
verdict.Rule, retentionTimepoint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffWidenedPositionTouchesAgreements 把 r1 复审指出的**已知风险**
|
||||
// 写成断言(而不是留一个词表去掩盖它)。
|
||||
//
|
||||
// 事实: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()
|
||||
store := newTestStore(t)
|
||||
const content = "用户昨天发来一卷验收纸条"
|
||||
// 前提:这条正文在旧规则下确实判 temp(否则本用例证明不了「覆盖」)。
|
||||
bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(bare): %v", err)
|
||||
}
|
||||
if bare.Tier != RetentionTemp || bare.Rule != retentionTimepoint {
|
||||
t.Fatalf("前提不成立:%q 旧规则判 %q/%q,want temp/%q", content, bare.Tier, bare.Rule, retentionTimepoint)
|
||||
}
|
||||
for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} {
|
||||
temporal := temporal
|
||||
t.Run(string(temporal), func(t *testing.T) {
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = temporal
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(%q): %v", temporal, err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionLong {
|
||||
t.Fatalf("temporal=%q 落库档位 = %q, want %q", temporal, tier, RetentionLong)
|
||||
}
|
||||
if expires != "" {
|
||||
t.Fatalf("temporal=%q 写了 expires_at = %q(非 temp 档必须为空)", temporal, expires)
|
||||
}
|
||||
if nat := readTemporalColumn(t, store, got.ID); nat != temporal {
|
||||
t.Fatalf("temporal 列 = %q, want %q", nat, temporal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalUnknownAndUnsetFollowOldRules 钉住任务书验收点③:`unknown`(以及未分型
|
||||
// 的空串)**不许自己判 temp** —— 它们必须原样交给 N1~N7,判据只有旧规则那一份。
|
||||
//
|
||||
// 三条断言方向不同,缺一条都测不出「方向反了」:
|
||||
//
|
||||
// 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:规则码必须
|
||||
// 是 `n6_timepoint`,**绝不能**是 `n6_timepoint_wide`(后者出现就说明位置判据被
|
||||
// 一个「拿不准」的答案放开了);
|
||||
// 2. `unknown` + 旧规则非命中的正文 ⇒ long(不许因为看着像一次性就判 temp);
|
||||
// 3. 未分型(空串)与 `unknown` 走同一条路(历史行的行为逐字不变)。
|
||||
func TestTemporalUnknownAndUnsetFollowOldRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("unknown_旧规则命中", func(t *testing.T) {
|
||||
item := habitItem("今天下雨了", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalUnknown
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
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(temporalWideTimepointCorpus()[0], tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalUnknown
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(unknown): %v", err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionLong || expires != "" {
|
||||
t.Fatalf("unknown 且旧规则不命中时落库 = (%q, %q), want (%q, 空) —— 拿不准就必须留",
|
||||
tier, expires, RetentionLong)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未分型_与unknown同路", func(t *testing.T) {
|
||||
for _, temporal := range []TemporalKind{TemporalUnset, TemporalUnknown} {
|
||||
item := habitItem("谢谢啦", tidyTestTime(t), 0.9)
|
||||
item.Temporal = temporal
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(%q): %v", temporal, err)
|
||||
}
|
||||
if verdict.Tier != RetentionTemp || verdict.Rule != retentionChatter {
|
||||
t.Fatalf("temporal=%q 时 %q 判成 %q/%q, want temp/%q(历史行的行为必须逐字不变)",
|
||||
temporal, item.Content, verdict.Tier, verdict.Rule, retentionChatter)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDoesNotTouchRestrictedSource 钉住与受限来源的优先级:群聊 /
|
||||
// 陌生人的行**不分型**(空串),因此时间性位不许把它们判成 temp —— 那会造出
|
||||
// 「对读面隐藏、却永远不会被后台清扫删除」的行(expire.go 跳过非 owner 来源)。
|
||||
func TestTemporalOneOffDoesNotTouchRestrictedSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
item := habitItem("今天下雨了", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
item.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1")
|
||||
got, err := store.Add(context.Background(), item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionTierUnset || expires != "" {
|
||||
t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalBitDrivesDeferThroughGate 钉住判定接线:闸开着时,位置被放开后判出的
|
||||
// temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),不是另开一条路径。
|
||||
func TestTemporalBitDrivesDeferThroughGate(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(temporalWideTimepointCorpus()[0], base, 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := store.Add(context.Background(), item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
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 就放开位置判据),
|
||||
// 而「拿不准一律留」才是 fail-safe。
|
||||
// 不做同义词映射是刻意的:将来接线时猜同义词的代价是单向的(猜成 one_off 就删记忆),
|
||||
// 而「拿不准一律 unknown」才是 fail-safe。
|
||||
func TestNormalizeTemporal(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -387,7 +219,7 @@ func TestNormalizeTemporal(t *testing.T) {
|
||||
// (Add 的 validate 会拒掉它们 —— 那是程序错误,不是模型输出)。
|
||||
func TestTemporalKindClosedSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ok := range []TemporalKind{TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown} {
|
||||
for _, ok := range temporalAllKinds() {
|
||||
if !ok.valid() {
|
||||
t.Fatalf("%q 应在闭集内", ok)
|
||||
}
|
||||
@@ -400,15 +232,6 @@ 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")
|
||||
@@ -417,8 +240,9 @@ func TestTemporalKindClosedSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMExtractorReadsTemporal 钉住验收点④的另一半:提取器**真的**在读模型给的
|
||||
// 这一位,且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。
|
||||
// TestLLMExtractorReadsTemporal 钉住产出侧:提取器**真的**在读模型给的这一位,
|
||||
// 且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。观测面的数据
|
||||
// 质量由这条用例守住。
|
||||
func TestLLMExtractorReadsTemporal(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
@@ -456,7 +280,7 @@ func TestLLMExtractorReadsTemporal(t *testing.T) {
|
||||
|
||||
// TestExtractionPromptCarriesTemporalEnum 钉住「提示词与闭集不漂移」:模型能看到的
|
||||
// 取值必须与 normalizeTemporal 认的四个值逐字一致(提示词只是辅助,但取值口径漂了
|
||||
// 就会让每条候选都被归成 unknown,这一位等于白加)。
|
||||
// 就会让每条候选都被归成 unknown,观测面等于白留)。
|
||||
func TestExtractionPromptCarriesTemporalEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, want := range []string{"temporal", "one_off", "recurring", "durable", "unknown"} {
|
||||
|
||||
Reference in new issue
Block a user