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).
323 lines
13 KiB
Go
323 lines
13 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|