Files
memoryweave/go/internal/pattern/pattern_test.go
T
小唯 afdab8fb6b fix(pattern): workflow branch default-retired - memory body no longer emitted as pattern Data (t_639c5a03 round3, Path B)
Round3 review: data source (LanceDB memory body text) has no parseable structured
tool_use sequence; DetectFromRecords guessing tool combos from natural language =
cooking without ingredients. Path A (structured tool_use) proven absent: MemoryRecord
0 tool fields, episodes/hit_events schema no tool field, Scan projects no tool col.
Decided Path B (honest retirement).

This commit = retirement hygiene: workflow branch emits cluster-longest body as Data
(120-char cut) producing 'whole-sentence memory conf=1.0' pseudo-pattern, violating
hard constraint 'memory body must not be pattern name'.
- Config adds WorkflowPatterns, default false (channel retired)
- DetectFromRecords runs workflow clustering only when explicitly enabled
- unit test: TestDetect_WorkflowRetiredByDefault feeds the exact sentence from review => 0 pattern
- TestDetect_Workflow now opts into WorkflowPatterns=true (gate still verified)
tool_combo branch (whitelist-only + conf>=0.5 + >=2 sources) kept: returns 0 on this data, no pollution.
PATTERN_RECALL stays OFF (80-pattern-recall.conf.disabled-20261030 untouched, no env key).
2026-10-30 23:36:23 +08:00

323 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pattern
import (
"strings"
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
)
// 构造 N 条同一工具组合的记忆(内容里带白名单、非通用词的真工具)。
func comboRecords(n int, combo string) []models.MemoryRecord {
// combo 形如 "jq sqlite3 rsync"——内容里出现这几个工具名(空格分隔,保证词边界命中)。
// 上下文用纯中文(非 ascii token),避免上下文词误当工具混进组合键。
recs := make([]models.MemoryRecord, 0, n)
for i := 0; i < n; i++ {
recs = append(recs, models.MemoryRecord{
ID: "m_" + string(rune('a'+i)),
Content: "用 " + combo + " 处理第" + string(rune('一'+i)) + "项",
Category: "distilled",
Vector: nil, // 走 bigram Jaccard 回退(确定性,不依赖网络)
})
}
return recs
}
// TestDetect_ToolCombo —— 3 条同工具组合记忆 → 检出 1 个 tool_combo pattern(AC2)。
func TestDetect_ToolCombo(t *testing.T) {
recs := comboRecords(3, "jq sqlite3 rsync")
pats := DetectFromRecords(recs, DefaultConfig())
var toolPatterns []Pattern
for _, p := range pats {
if p.Type == "tool_combo" {
toolPatterns = append(toolPatterns, p)
}
}
if len(toolPatterns) != 1 {
t.Fatalf("期望 1 个 tool_combo pattern,实际 %d: %#v", len(toolPatterns), toolPatterns)
}
p := toolPatterns[0]
if p.Name != "tool_combo:jq+rsync+sqlite3" {
t.Fatalf("命名键不符: got %q (工具应排序去重为 jq,rsync,sqlite3)", p.Name)
}
if len(p.Tools) != 3 {
t.Fatalf("Tools 应含 3 个,实际 %v", p.Tools)
}
// confidence = min(freq*0.1,1.0) = min(3*0.1,1.0) ≈ 0.3(浮点容差 1e-9)
if p.Confidence < 0.3-1e-9 || p.Confidence > 0.3+1e-9 {
t.Fatalf("confidence 期望 ≈0.3,实际 %f", p.Confidence)
}
if len(p.SourceTasks) != 3 {
t.Fatalf("SourceTasks 应含 3 条记忆 ID,实际 %v", p.SourceTasks)
}
}
// TestDetect_BelowThreshold —— 1 条同组合(<2)→ 不产 tool_combo(AC2 反例;门槛 t_639c5a03=2)。
func TestDetect_BelowThreshold(t *testing.T) {
recs := comboRecords(1, "jq sqlite3 rsync")
pats := DetectFromRecords(recs, DefaultConfig())
for _, p := range pats {
if p.Type == "tool_combo" {
t.Fatalf("1 条同组合不应产 tool_combo,实际产出: %#v", p)
}
}
}
// TestDetect_Workflow —— 3 条近同文(向量近似 1.0 或 bigram 高度重合)→ 1 个 workflow 簇。
// t_639c5a03 三审后 workflow 分支默认退役,需显式 WorkflowPatterns=true 才跑(验证门仍在)。
func TestDetect_Workflow(t *testing.T) {
// 向量相同(cosine=1.0 ≥ 0.85)且 3 条 → 应聚成 1 簇。
vec := []float32{1.0, 0.0, 0.0, 0.0}
recs := []models.MemoryRecord{
{ID: "w1", Content: "重启服务后拉日志", Category: "distilled", Vector: vec},
{ID: "w2", Content: "重启服务后拉日志", Category: "distilled", Vector: vec},
{ID: "w3", Content: "重启服务后拉日志", Category: "distilled", Vector: vec},
}
cfg := DefaultConfig()
cfg.WorkflowPatterns = true
pats := DetectFromRecords(recs, cfg)
var wf []Pattern
for _, p := range pats {
if p.Type == "workflow" {
wf = append(wf, p)
}
}
if len(wf) != 1 {
t.Fatalf("3 条近同文应聚 1 个 workflow pattern,实际 %d: %#v", len(wf), pats)
}
if !strings.HasPrefix(wf[0].Name, "workflow:cluster-3-") {
t.Fatalf("workflow 命名应含 cluster-3-<fp>,实际 %q", wf[0].Name)
}
}
// TestDetect_WorkflowRetiredByDefault —— 默认 Config 下 workflow 分支关闭:喂 3 条近同文
// 记忆(含完整自然语言句子)⇒ 0 pattern。t_639c5a03 三审「不许把记忆正文当 pattern name」硬约束。
func TestDetect_WorkflowRetiredByDefault(t *testing.T) {
vec := []float32{1.0, 0.0, 0.0, 0.0}
recs := []models.MemoryRecord{
{ID: "s1", Content: "给 memories 加新列必须同时改 Rust 写 builder *_col.push", Category: "distilled", Vector: vec},
{ID: "s2", Content: "给 memories 加新列必须同时改 Rust 写 builder *_col.push", Category: "distilled", Vector: vec},
{ID: "s3", Content: "给 memories 加新列必须同时改 Rust 写 builder *_col.push", Category: "distilled", Vector: vec},
}
pats := DetectFromRecords(recs, DefaultConfig())
for _, p := range pats {
t.Fatalf("默认 Config 不应产出任何 pattern(含 workflow 整句),实际: %#v", p)
}
}
// TestDetect_Empty —— 空输入 → 空输出不报错(AC1 空库前提)。
func TestDetect_Empty(t *testing.T) {
if pats := DetectFromRecords(nil, DefaultConfig()); len(pats) != 0 {
t.Fatalf("空输入应 0 pattern,实际 %d", len(pats))
}
}
// TestExtractTools —— 词边界:「google」不应命中工具 "go"。
func TestExtractTools_WordBoundary(t *testing.T) {
tools := ExtractTools("I like google and rsync the backup with jq")
// 应含 rsync、jq;不应含 go("google" 被词边界排除;且 go 已进通用词黑名单)
found := map[string]bool{}
for _, s := range tools {
found[s] = true
}
if !found["rsync"] || !found["jq"] {
t.Fatalf("应识别 rsync/jq,实际 %v", tools)
}
if found["go"] {
t.Fatalf("'google' 误命中工具 'go'(词边界失效): %v", tools)
}
}
// ── t_639c5a03:垃圾 pattern 判据单测(AC2)──────────────────────────────
// fakeMem 一条带指定 ID 的假蒸馏记忆(向量 nil → 走 bigram 回退,确定性)。
func fakeMem(id, content string) models.MemoryRecord {
return models.MemoryRecord{ID: id, Content: content, Category: "distilled", Vector: nil}
}
// TestDetect_JunkTokensRejected —— 3 条含 .md / 2026 / 标点 的假记忆 ⇒ 0 pattern(AC2 第一条)。
// 覆盖卡现象里的垃圾样本:.md、.md+2026-09-23、2026+23 全不得被当工具组合。
func TestDetect_JunkTokensRejected(t *testing.T) {
recs := []models.MemoryRecord{
fakeMem("j1", "今天把结果导出成 .md 文件"),
fakeMem("j2", "版本 .md+2026-09-23 的构建产物"),
fakeMem("j3", "数字 2026+23 是什么意义"),
}
pats := DetectFromRecords(recs, DefaultConfig())
for _, p := range pats {
t.Fatalf("垃圾记忆不应产出任何 pattern,实际 %#v", p)
}
}
// TestDetect_RealToolComboNeeds2Sources —— 2 条真工具共现(jq+rsync)⇒ 1 pattern(AC2 第二条)。
// 同时验证门槛 ≥2 条独立来源(单条不产)。
func TestDetect_RealToolComboNeeds2Sources(t *testing.T) {
// 单条真工具组合 → 不产(门槛 2)
one := DetectFromRecords([]models.MemoryRecord{
fakeMem("o1", "用 jq 和 rsync 同步 json"),
}, DefaultConfig())
for _, p := range one {
if p.Type == "tool_combo" {
t.Fatalf("单条真工具组合不应产 pattern(门槛≥2),实际 %#v", p)
}
}
// 2 条同真组合 → 1 个 tool_combo,成员 jq+rsync,支撑 2 条
two := DetectFromRecords([]models.MemoryRecord{
fakeMem("a1", "先用 jq 解析再 rsync 备份"),
fakeMem("a2", "jq 提取字段后 rsync 同步"),
}, DefaultConfig())
var tc []Pattern
for _, p := range two {
if p.Type == "tool_combo" {
tc = append(tc, p)
}
}
if len(tc) != 1 {
t.Fatalf("2 条同真组合应产 1 个 tool_combo,实际 %d: %#v", len(tc), two)
}
if tc[0].Name != "tool_combo:jq+rsync" {
t.Fatalf("命名键不符: got %q", tc[0].Name)
}
if len(tc[0].SourceTasks) != 2 {
t.Fatalf("支撑应为 2 条独立来源,实际 %v", tc[0].SourceTasks)
}
}
// TestDetect_SingletonToolNoCombo —— 二轮:单工具(即使白名单、≥2 来源)不产 tool_combo(「组合」语义)。
func TestDetect_SingletonToolNoCombo(t *testing.T) {
pats := DetectFromRecords([]models.MemoryRecord{
fakeMem("s1", "用 jq 解析配置文件甲"),
fakeMem("s2", "用 jq 解析配置文件乙"),
}, DefaultConfig())
for _, p := range pats {
if p.Type == "tool_combo" {
t.Fatalf("单工具不应产 tool_combo(组合需≥2成员),实际 %#v", p)
}
}
}
// TestExtractTools_JunkTokenFilter —— 形态级:.md/2026/日期/纯标点/扩展名/停用词/通用词全被滤。
// 二轮(小怡裁定):git/curl 从"应识别"翻转为"必须滤掉"(通用词黑名单)。
func TestExtractTools_JunkTokenFilter(t *testing.T) {
content := "处理 .md 和 2026 与 2026-09-23 以及 2026+23 与 report.md 与 a.py" +
" 还有 git status 和 curl -I https://x 和 ... 和 step 1 以及 sqlite3 dump 和 jq 解析"
tools := ExtractTools(content)
found := map[string]bool{}
for _, s := range tools {
found[s] = true
}
// 垃圾与通用词一律不得出现
for _, junk := range []string{".md", "2026", "2026-09-23", "2026+23", "report", "a", "step",
"https", "-i", "git", "curl"} {
if found[junk] {
t.Fatalf("垃圾/通用 token %q 不应被当工具,实际 %v", junk, tools)
}
}
for _, want := range []string{"sqlite3", "jq"} {
if !found[want] {
t.Fatalf("应识别真标识 %q,实际 %v", want, tools)
}
}
}
// TestExtractTools_RoundTwoJunkClasses —— 小怡二轮 dry-run 里 14 条样本的垃圾类别逐项回归:
// hash 片段 / 短 hash / uuid 段 / 数值+单位 / 数字编号目录名 / 通用词 / CVE、协议碎片
// —— 全部不得出现;只放行白名单真工具。
func TestExtractTools_RoundTwoJunkClasses(t *testing.T) {
content := "参考 05-ai-agent 目录,产物 2.6mb 与 512kb 与 100ms," +
"commit 374b20f98c44 与 b713b48a70ad 与 ac10 与 635dccc0," +
"接口走 bot-chat 与 mcp 与 api,用 git grep go curl 查询," +
"ipv4 与 cve-2026-99999 与 dispatch-backlog.jsonl 与 38-patternfix," +
"真工具是 docker 与 sqlite3 与 jq 与 rsync"
tools := ExtractTools(content)
found := map[string]bool{}
for _, s := range tools {
found[s] = true
}
for _, junk := range []string{
"05-ai-agent", "38-patternfix", "2.6mb", "512kb", "100ms",
"374b20f98c44", "b713b48a70ad", "ac10", "635dccc0",
"bot-chat", "mcp", "api", "git", "grep", "go", "curl",
"ipv4", "cve-2026-99999", "dispatch-backlog.jsonl",
} {
if found[junk] {
t.Fatalf("二轮垃圾类别 token %q 漏网,实际 %v", junk, tools)
}
}
for _, want := range []string{"docker", "sqlite3", "jq", "rsync"} {
if !found[want] {
t.Fatalf("白名单真工具 %q 应被识别,实际 %v", want, tools)
}
}
}
// TestFormPredicates —— 形态级判据的单元断言(hash/uuid/数值单位/编号目录)。
func TestFormPredicates(t *testing.T) {
hashBad := []string{"374b20f98c44", "b713b48a70ad", "635dccc0", "ac10", "b713-48a7", "deadbeef"}
hashGood := []string{"gcc-12", "redis-cli", "cafe", "beef", "fd", "systemd-run", "golangci-lint", "hermes"}
for _, s := range hashBad {
if !isHashFragment(s) {
t.Fatalf("isHashFragment(%q) 应为 true", s)
}
}
for _, s := range hashGood {
if isHashFragment(s) {
t.Fatalf("isHashFragment(%q) 应为 false(真工具标识被误杀)", s)
}
}
numBad := []string{"2.6mb", "512kb", "100ms", "3x", "50%", "1024"}
numGood := []string{"sqlite3", "python3", "gcc-12", "v10"} // v10 由 isNumericFragment 拦,这里不误判成单位碎片
for _, s := range numBad {
if !isNumericUnitFragment(s) {
t.Fatalf("isNumericUnitFragment(%q) 应为 true", s)
}
}
for _, s := range numGood {
if isNumericUnitFragment(s) {
t.Fatalf("isNumericUnitFragment(%q) 应为 false", s)
}
}
dirBad := []string{"05-ai-agent", "38-patternfix", "10-after-sidecar"}
dirGood := []string{"gcc-12", "systemd-run", "redis-cli", "obsidian-vault"}
for _, s := range dirBad {
if !isNumberedDirName(s) {
t.Fatalf("isNumberedDirName(%q) 应为 true", s)
}
}
for _, s := range dirGood {
if isNumberedDirName(s) {
t.Fatalf("isNumberedDirName(%q) 应为 false", s)
}
}
}
// TestIsValidPatternName_Gate —— 写入闸:垃圾/单成员/通用词命名键全拒,白名单多成员键/工作流键放行。
func TestIsValidPatternName_Gate(t *testing.T) {
bad := []string{
"tool_combo:.md", "tool_combo:2026+23", "tool_combo:.md+2026-09-23",
"tool_combo:2026", "tool_combo:report", "tool_combo:2026-09-23",
"tool_combo:usage_track", "tool_combo:t_9143c968", "tool_combo:a+b",
"tool_combo:log", "tool_combo:",
// 二轮新增:单成员、通用词成员、hash/数值/目录碎片成员
"tool_combo:sqlite3", "tool_combo:jq", "tool_combo:git+jq",
"tool_combo:curl+git+jq", "tool_combo:374b20f98c44+jq",
"tool_combo:05-ai-agent", "tool_combo:2.6mb", "tool_combo:ac10",
"tool_combo:cve-2026-99999", "tool_combo:ipv4+jq", "tool_combo:bot-chat",
}
for _, name := range bad {
if IsValidPatternName(name) {
t.Fatalf("垃圾命名键 %q 不应通过闸门", name)
}
}
good := []string{
"tool_combo:jq+rsync", "tool_combo:docker+sqlite3",
"tool_combo:hermes+zhiyi+jq",
"workflow:cluster-3-abcdef12", "workflow:cluster-2-00aa11bb",
}
for _, name := range good {
if !IsValidPatternName(name) {
t.Fatalf("合法命名键 %q 应通过闸门", name)
}
}
}