Files
memoryweave/go/internal/pattern/pattern.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

711 lines
23 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.
// 织忆 MemoryWeave — L2 模式检测引擎(t_d32e5f8a)
//
// 背景:MemOS 血统的 L2「跨任务重复模式」检测。原实现活在 Python daemon 的
// main_loop(30s tick),2026-09-24 为 state-db 写安全整体退役后 L2 pattern 蒸馏
// 停摆(graph_nodes type='pattern' 停为 0 行;旧 Python daemon 残留的 85 行是
// type='pattern_stat',id 全 NULL、语义为 L1 命名空间统计快照,非 L2 pattern / 2026-09-18)。本包把 L1→L2 蒸馏
// 复活成**短命单发**(modernc sqlite 直连 graph.db,开/关不占长连接,不碰 state.db),
// 并按设计 §4.1 叠加两条检测规则:
// - 工具组合频率:同一 tool 组合(成员 ∈ knownTools 白名单、≥2 成员、排除通用词)
// 被 ≥2 条独立来源记忆支撑 → tool_combo pattern(confidence = min(freq*0.1, 1.0));
// 落库另需 confidence ≥ MinWriteConfidence=0.5(判据收紧史见 t_639c5a03)
// - 相似解决路径聚类:cosine ≥0.85 成簇且簇内 ≥3 条 → workflow pattern
//
// 存储:复用现有 graph_nodes(type='pattern'),**不另开新表**(避免与现存图谱查询割裂;
// 表内另有 85 行 type='pattern_stat' 属旧 daemon 统计快照,与 L2 pattern 无关)。
// namespace='daemon-distill' 沿用旧 daemon 约定。
package pattern
import (
"fmt"
"math"
"sort"
"strings"
"github.com/xiaoxue/memoryweave/internal/models"
)
// Pattern 一个被检测出的 L2 模式。
type Pattern struct {
ID string
Type string // "tool_combo" | "workflow"
Name string // 稳定命名键(同组合去重)
Data string // tool_combo=排序后工具串;workflow=共同步骤/代表路径摘要
Confidence float64
SourceTasks []string // 支撑该 pattern 的记忆 ID(设计 source_tasks)
Tools []string // tool_combo 专用
}
// Config 检测参数(设计 §4.1)。
type Config struct {
MinComboFreq int // 工具组合需 ≥ N 条**独立来源记忆**才产 pattern(默认 2,t_639c5a03 门槛)
ClusterThresh float64 // 相似路径聚类 cosine 阈值(默认 0.85)
MinClusterSize int // 簇内 ≥ N 条才产 workflow pattern(默认 3)
MaxRecords int // 单次扫描参与检测的记忆上限(防 O(n²) 爆炸,默认 500)
TopKPatternList int // 召回/列出取前 N(默认 10)
// WorkflowPatterns 是否启用「相似解决路径聚类」workflow 分支。
// t_639c5a03 三审(小怡 2026-10-30)后决定**默认退役**:该分支把簇内最长记忆正文当
// Data(truncate 120 字),在自然语言语料上产出即「整句记忆正文 conf=1.0」的伪 pattern,
// 违反「不许把记忆正文当 pattern name」硬约束。通道退役,默认关闭;仅当显式开启才跑。
WorkflowPatterns bool
}
// DefaultConfig 设计参数。WorkflowPatterns 默认 false = workflow 分支退役(t_639c5a03 三审)。
func DefaultConfig() Config {
return Config{
MinComboFreq: 2,
ClusterThresh: 0.85,
MinClusterSize: 3,
MaxRecords: 500,
TopKPatternList: 10,
WorkflowPatterns: false,
}
}
// MinWriteConfidence 落库置信度门槛(小怡二轮裁定 #5,2026-10-30):conf<0.5 一律不写库。
// tool_combo 的 conf=来源数×0.1 ⇒ 实际要求 ≥5 条独立来源;workflow 簇需平均相似度 ≥0.5。
const MinWriteConfidence = 0.5
// knownTools 真实命令/CLI 名(小写)白名单 —— tool_combo 成员的**唯一**来源。
// t_639c5a03 二轮(小怡 2026-10-30 裁定):原"含 -/数字即可"的命令式标识兜底放行是
// hash 片段(374b20f98c44/ac10)、目录名(05-ai-agent)、文件名(dispatch-backlog.jsonl)、
// CVE/协议碎片(cve-2026-99999/ipv4)的进入路径,已整体移除;白名单外一律不进组合键。
// 同时移除过于通用的条目(git/go/curl/grep/cat/ls/find/…,见 genericTools)。
// 新增工具 = 改这张表(可审计),不再靠形态猜测。
var knownTools = map[string]bool{
"jq": true, "yq": true, "sed": true, "awk": true,
"wget": true, "tar": true, "zip": true, "unzip": true,
"docker": true, "kubectl": true, "helm": true, "npm": true, "pip": true,
"python": true, "python3": true, "node": true, "pnpm": true,
"make": true, "cmake": true, "gcc": true, "gcc-12": true, "clang": true,
"sqlite3": true, "psql": true, "mysql": true, "redis-cli": true,
"ssh": true, "scp": true, "rsync": true, "systemctl": true, "journalctl": true,
"systemd-run": true, "crontab": true, "rg": true, "ripgrep": true,
"fd": true, "fzf": true, "gh": true, "gcloud": true, "aws": true,
"hermes": true, "zhiyi": true, "obsidian": true, "esbuild": true,
"tsc": true, "cargo": true, "rustc": true, "swift": true, "brew": true,
"bash": true, "sh": true, "zsh": true, "openssl": true,
"flock": true, "nc": true,
"java": true, "javac": true, "mvn": true, "gradle": true, "dotnet": true,
"remotion": true, "uv": true, "pytest": true, "gofmt": true, "golangci-lint": true,
}
// genericTools 过于通用的词/命令(小怡二轮裁定 #4 点名 git/go/curl/grep/bot-chat/mcp/api「等」):
// 几乎任何任务都命中 ⇒ 进 pattern 等于往召回 top-3 灌噪声。即使形态合格也**绝不**作为组合成员。
// md/json/py/api/log/db 等亦在 toolStopwords 覆盖内,这里重复登记作双保险。
// 后半段是 shell 基础词(cat/ls/find/…,「等」的裁量延伸:与 git 同级别的散文高频词)。
var genericTools = map[string]bool{
"git": true, "go": true, "curl": true, "grep": true, "bot-chat": true,
"mcp": true, "api": true, "md": true, "json": true, "py": true,
"cat": true, "ls": true, "cp": true, "mv": true, "rm": true, "mkdir": true,
"head": true, "tail": true, "sort": true, "uniq": true, "wc": true, "find": true,
"xargs": true, "kill": true, "ps": true, "chmod": true, "touch": true,
"diff": true, "echo": true, "ping": true, "lsof": true, "tar": true,
}
// commonExts 常见文件扩展名(小写,含点)。token 以此结尾 ⇒ 文件名/扩展名,不是工具。
var commonExts = map[string]bool{
".md": true, ".go": true, ".json": true, ".py": true, ".js": true, ".ts": true,
".tsx": true, ".jsx": true, ".yaml": true, ".yml": true, ".toml": true, ".sh": true,
".db": true, ".sql": true, ".csv": true, ".log": true, ".txt": true, ".html": true,
".css": true, ".rs": true, ".c": true, ".h": true, ".cpp": true, ".conf": true,
".xml": true, ".ini": true, ".env": true, ".pdf": true, ".png": true, ".jpg": true,
".jpeg": true, ".svg": true, ".gif": true, ".ipynb": true, ".lock": true, ".sum": true,
".bak": true, ".tmp": true, ".gz": true, ".mp4": true, ".mp3": true, ".docx": true,
".xlsx": true, ".pptx": true, ".service": true, ".sock": true, ".socket": true,
}
// toolStopwords 常见停用词(小写):高频非工具词,绝不作为工具名(卡要求第 1 条)。
var toolStopwords = map[string]bool{
"the": true, "and": true, "for": true, "with": true, "that": true, "this": true,
"from": true, "into": true, "then": true, "when": true, "what": true, "they": true,
"have": true, "will": true, "your": true, "not": true, "but": true, "all": true,
"can": true, "use": true, "via": true, "per": true, "are": true, "was": true,
"one": true, "two": true, "ok": true, "yes": true, "true": true, "false": true,
"null": true, "none": true, "etc": true, "vs": true, "id": true, "ids": true,
"ns": true, "log": true, "logs": true, "file": true, "files": true, "path": true,
"line": true, "lines": true, "name": true, "type": true, "data": true, "time": true,
"date": true, "test": true, "note": true, "step": true, "steps": true, "first": true,
"last": true, "next": true, "top": true, "api": true, "http": true, "https": true,
"url": true, "uri": true, "json": true, "yaml": true, "yml": true, "md": true,
"txt": true, "tmp": true, "dev": true, "prod": true, "src": true, "doc": true,
"docs": true, "ref": true, "config": true, "default": true, "error": true,
"errors": true, "warn": true, "info": true, "debug": true, "fail": true,
"failed": true, "success": true, "start": true, "stop": true, "check": true,
"list": true, "get": true, "set": true, "new": true, "old": true, "fix": true,
"pr": true, "mr": true, "db": true,
}
// DetectFromRecords 纯检测核心:从 L1 记忆列表发现重复模式。
// 不读 LanceDB / 不写库,只做「记录 → pattern 候选」,便于确定性单测。
// 返回合并去重后的 pattern 列表(同 Name 合并 source_tasks、取更高 confidence)。
func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
if cfg.MinComboFreq <= 0 {
cfg.MinComboFreq = 2
}
if cfg.MinClusterSize <= 0 {
cfg.MinClusterSize = 3
}
if cfg.ClusterThresh <= 0 {
cfg.ClusterThresh = 0.85
}
lim := cfg.MaxRecords
if lim <= 0 {
lim = 500
}
if len(records) > lim {
records = records[:lim]
}
combined := make(map[string]*Pattern) // Name → merged pattern
order := []string{}
merge := func(p Pattern) {
if ex, ok := combined[p.Name]; ok {
ex.SourceTasks = append(ex.SourceTasks, p.SourceTasks...)
if p.Confidence > ex.Confidence {
ex.Confidence = p.Confidence
}
return
}
combined[p.Name] = &p
order = append(order, p.Name)
}
// 1. 工具组合频率分析
comboCount := map[string]*struct {
tools []string
tasks []string
}{}
for _, rec := range records {
tools := ExtractTools(rec.Content)
if len(tools) == 0 {
continue
}
tools = dedupeSorted(tools)
key := "tool_combo:" + strings.Join(tools, "+")
c := comboCount[key]
if c == nil {
c = &struct {
tools []string
tasks []string
}{tools: tools}
comboCount[key] = c
}
c.tasks = append(c.tasks, rec.ID)
}
for name, c := range comboCount {
// 二轮:「组合」语义 —— 单工具无共现信息量,≥2 个成员才产 tool_combo。
if len(c.tools) < 2 {
continue
}
// t_639c5a03 门槛:支撑数按**独立来源记忆**(去重且非空 ID)计,< MinComboFreq 直接丢。
seen := map[string]bool{}
var sources []string
for _, id := range c.tasks {
if id != "" && !seen[id] {
seen[id] = true
sources = append(sources, id)
}
}
if len(sources) < cfg.MinComboFreq {
continue
}
merge(Pattern{
Type: "tool_combo",
Name: name,
Data: strings.Join(c.tools, "+"),
Tools: c.tools,
Confidence: confidenceFromFreq(len(sources)),
SourceTasks: sources,
})
}
// 2. 相似解决路径聚类(cosine ≥ 阈值 成簇,簇内 ≥ N 条)
// t_639c5a03 三审:该分支把记忆正文当 pattern Data(整句注入),默认退役;仅 cfg.WorkflowPatterns 显式开启才跑。
if cfg.WorkflowPatterns {
paths := clusterPaths(records, cfg.ClusterThresh, cfg.MinClusterSize)
for _, cluster := range paths {
// 代表路径 = 簇内最长 content(共同步骤近似)
rep := ""
var ids []string
for _, r := range cluster {
ids = append(ids, r.ID)
if len(r.Content) > len(rep) {
rep = r.Content
}
}
conf := clusterConfidence(cluster) // 平均两两 cosine,封顶 1.0
if conf > 1.0 {
conf = 1.0
}
// 簇名带代表内容哈希前 8 位 —— 同 size 不同内容簇不碰撞(否则 cluster-3 撞簇)
name := fmt.Sprintf("workflow:cluster-%d-%s", len(cluster), contentFingerprint(rep))
merge(Pattern{
Type: "workflow",
Name: name,
Data: truncateForPattern(rep, 120),
Confidence: conf,
SourceTasks: ids,
})
}
}
out := make([]Pattern, 0, len(order))
for _, n := range order {
p := *combined[n]
p.SourceTasks = dedupeStrings(p.SourceTasks)
if p.Type == "tool_combo" {
p.ID = "pat_" + p.Name
} else {
p.ID = "pat_" + p.Name
}
out = append(out, p)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Type != out[j].Type {
return out[i].Type < out[j].Type
}
return out[i].Name < out[j].Name
})
return out
}
// confidenceFromFreq 设计公式:min(freq*0.1, 1.0)。
func confidenceFromFreq(freq int) float64 {
c := float64(freq) * 0.1
if c > 1.0 {
c = 1.0
}
return c
}
// isNumericUnitFragment 数值+单位碎片(小怡二轮裁定 #2):^\d+(\.\d+)?(mb|kb|gb|tb|ms|ns|us|s|m|h|x|%)?$
// 例:"2.6mb"、"512kb"、"100ms"、"3x"。纯数字无单位形态由 isNumericFragment 拦截,这里不重复依赖它。
func isNumericUnitFragment(tok string) bool {
i := 0
for i < len(tok) && tok[i] >= '0' && tok[i] <= '9' {
i++
}
if i == 0 {
return false // 无数字前缀 ⇒ 不是该形态
}
if i < len(tok) && tok[i] == '.' {
j := i + 1
for j < len(tok) && tok[j] >= '0' && tok[j] <= '9' {
j++
}
if j == i+1 {
return false // '.' 后无数字 ⇒ 不是该形态
}
i = j
}
switch strings.ToLower(tok[i:]) {
case "":
return true // 纯数字
case "mb", "kb", "gb", "tb", "ms", "ns", "us", "s", "m", "h", "x", "%":
return true
}
return false
}
// isHexOnly token 是否只由 [0-9a-f] 构成(非空)。
func isHexOnly(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}
// isHashFragment hash/uuid 碎片(小怡二轮裁定 #1):
// - 纯 [0-9a-f] 且 len≥6 ⇒ hash 片段(374b20f98c44 / b713b48a70ad / 635dccc0);
// - 纯 [0-9a-f] 且 len≥4 且含数字 ⇒ 短 hash(ac10);'cafe'/'beef' 类纯字母词放行;
// - 形如 <hex≥2>-<hex≥2> 前缀 ⇒ uuid 段(^[0-9a-f]{2,}-[0-9a-f]{2,},如 b713-48a7)。
// 'gcc-12'(gcc 非 hex)、'redis-cli'(r 非 hex)不受影响。
func isHashFragment(tok string) bool {
if idx := strings.IndexByte(tok, '-'); idx > 0 {
head, rest := tok[:idx], tok[idx+1:]
tail := rest
if k := strings.IndexByte(rest, '-'); k >= 0 {
tail = rest[:k]
}
if len(head) >= 2 && isHexOnly(head) && len(tail) >= 2 && isHexOnly(tail) {
return true
}
}
if !isHexOnly(tok) {
return false
}
if len(tok) >= 6 {
return true
}
return len(tok) >= 4 && strings.ContainsAny(tok, "0123456789")
}
// isNumberedDirName 数字编号目录名(小怡二轮裁定 #3):^\d+(-[a-z]+)+$
// 例:"05-ai-agent"、"38-patternfix"。"gcc-12"(首段非数字)不受影响。
func isNumberedDirName(tok string) bool {
parts := strings.Split(tok, "-")
if len(parts) < 2 {
return false
}
first := parts[0]
if first == "" {
return false
}
for i := 0; i < len(first); i++ {
if first[i] < '0' || first[i] > '9' {
return false // 首段非纯数字
}
}
for _, seg := range parts[1:] {
if seg == "" {
return false
}
for i := 0; i < len(seg); i++ {
if seg[i] < 'a' || seg[i] > 'z' {
return false // 某段含数字/非法字符
}
}
}
return true
}
// clusterPaths 贪心成簇:为每条未分配记忆开一个簇,把与其 cosine≥阈值 的其余未分配
// 记忆并入;最终只保留 size≥minSize 的簇。向量缺失时用记录自带 Vector,全缺则退到
// 文本 bigram Jaccard(确定性,不依赖网络)。
func clusterPaths(records []models.MemoryRecord, thresh float64, minSize int) [][]models.MemoryRecord {
n := len(records)
if n < minSize {
return nil
}
type sig struct {
vec []float32
big map[string]bool
text string
}
sigs := make([]sig, n)
for i, r := range records {
sigs[i] = sig{vec: r.Vector, text: r.Content}
}
sim := func(a, b int) float64 {
sa, sb := sigs[a], sigs[b]
if len(sa.vec) > 0 && len(sb.vec) > 0 {
return cosineF32(sa.vec, sb.vec)
}
if len(sa.vec) == 0 && len(sb.vec) == 0 {
return bigramJaccard(sa.text, sb.text)
}
return 0
}
assigned := make([]bool, n)
var clusters [][]models.MemoryRecord
for i := 0; i < n; i++ {
if assigned[i] {
continue
}
cluster := []models.MemoryRecord{records[i]}
assigned[i] = true
for j := i + 1; j < n; j++ {
if assigned[j] {
continue
}
if sim(i, j) >= thresh {
cluster = append(cluster, records[j])
assigned[j] = true
}
}
if len(cluster) >= minSize {
clusters = append(clusters, cluster)
}
}
return clusters
}
// clusterConfidence 簇内平均两两相似度(cosine 或 Jaccard)。
func clusterConfidence(cluster []models.MemoryRecord) float64 {
n := len(cluster)
if n < 2 {
return 1.0
}
sum := 0.0
pairs := 0
for i := 0; i < n; i++ {
for j := i + 1; j < n; j++ {
var s float64
a, b := cluster[i], cluster[j]
if len(a.Vector) > 0 && len(b.Vector) > 0 {
s = cosineF32(a.Vector, b.Vector)
} else {
s = bigramJaccard(a.Content, b.Content)
}
sum += s
pairs++
}
}
if pairs == 0 {
return 1.0
}
return sum / float64(pairs)
}
// ExtractTools 从记忆内容提取工具/命令名(t_639c5a03 二轮:白名单唯一来源)。
// 判据(卡·必须实现 #1「来自 tool_use 记录或白名单」+ 小怡二轮"更硬判据"取向):
// 成员必须**命中 knownTools 白名单**(词边界),且过 isValidTool 形态闸(通用词黑名单、
// hash/uuid 片段、数值+单位、数字编号目录名、扩展名、日期/版本片段、停用词等)。
//
// 旧实现的"命令式标识"兜底(白名单外、含 -/数字即放行)是二轮 dry-run 里
// hash 片段(374b20f98c44/ac10)、目录名(05-ai-agent)、文件名(dispatch-backlog.jsonl)、
// CVE/协议碎片(cve-2026-99999/ipv4)的进入路径,已整体移除。
// 确定性:结果排序去重。
func ExtractTools(content string) []string {
lower := strings.ToLower(content)
out := make([]string, 0, 4)
for t := range knownTools {
// 词边界匹配(避免 "go" 命中 "google")
if isValidTool(t) && tokenPresent(lower, t) {
out = append(out, t)
}
}
sort.Strings(out)
return out
}
// isValidTool token 形态判据(lowercase):是否为真实命令/工具标识的形状。
// 排除:长度 <2 或 >32;含 '/'、'\\'、'_'(路径、usage_track 键、t_9143c968 任务 ID);
// 以 '.' 开头(".md");无字母(纯数字/纯标点/数字+分隔符);以常见扩展名结尾
// (report.md / a.py);数字/日期/版本片段(2026、2026-09-23、2026+23、09:23、1.2.3、v1.2.3);
// 停用词。
func isValidTool(tok string) bool {
if len(tok) < 2 || len(tok) > 32 {
return false
}
if genericTools[tok] {
return false // 通用词黑名单(二轮 #4)
}
if strings.ContainsAny(tok, `/_\`) {
return false
}
if tok[0] == '.' || tok[0] == '-' {
return false // 隐藏文件 / CLI flag
}
hasLetter := false
for i := 0; i < len(tok); i++ {
if c := tok[i]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
hasLetter = true
break
}
}
if !hasLetter {
return false
}
if i := strings.LastIndexByte(tok, '.'); i > 0 && commonExts[tok[i:]] {
return false
}
if isNumericFragment(tok) {
return false
}
if isNumericUnitFragment(tok) {
return false // 二轮 #2:2.6mb / 512kb / 100ms
}
if isHashFragment(tok) {
return false // 二轮 #1:374b20f98c44 / ac10 / uuid 片段
}
if isNumberedDirName(tok) {
return false // 二轮 #3:05-ai-agent / 38-patternfix
}
if toolStopwords[tok] {
return false
}
return true
}
// isNumericFragment 纯数字 / 日期 / 时间 / 版本片段:
// - 只由数字与分隔符 [-+:./] 组成("2026"、"2026-09-23"、"2026+23"、"09:23"、"1.2.3"),或
// - 至多一个前导字母 + 其余全为数字/分隔符("v1.2.3"、"v2")。
//
// "gcc-12"/"sqlite3" 这类字母出现在数字之前且不止前导一个 ⇒ 正常标识,返回 false。
func isNumericFragment(tok string) bool {
if tok == "" {
return false
}
seenDigit := false
rest := tok
if c := rest[0]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
rest = rest[1:] // 至多剥一个前导字母
}
for _, r := range rest {
switch {
case r >= '0' && r <= '9':
seenDigit = true
case r == '-' || r == '+' || r == ':' || r == '.' || r == '/':
// 分隔符放行
default:
return false // 还有别的字母/字符 ⇒ 不是纯数字片段
}
}
return seenDigit
}
// tokenPresent 检查 lower 文本中 t 是否作为独立 token 出现(词边界,避免 "go" 命中 "google")。
func tokenPresent(lower, t string) bool {
if t == "" {
return false
}
// 切词精确匹配
for _, w := range strings.Fields(lower) {
// 剥掉包裹的引号/逗号/括号
w = strings.Trim(w, `"',()[]|;:`)
if w == t {
return true
}
}
// 空格/首尾边界
if strings.Contains(lower, " "+t+" ") || strings.HasPrefix(lower, t+" ") || strings.HasSuffix(lower, " "+t) {
return true
}
return false
}
func dedupeSorted(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
sort.Strings(out)
return out
}
func dedupeStrings(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
func cosineF32(a, b []float32) float64 {
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
return 0
}
var dot, na, nb float64
for i := range a {
x, y := float64(a[i]), float64(b[i])
dot += x * y
na += x * x
nb += y * y
}
if na == 0 || nb == 0 {
return 0
}
c := dot / (float64(math.Sqrt(na)) * float64(math.Sqrt(nb)))
if c > 1.0 {
c = 1.0
}
if c < -1.0 {
c = -1.0
}
return c
}
// bigramJaccard 字符 bigram Jaccard 相似度(无向量时的确定性回退)。
func bigramJaccard(a, b string) float64 {
bg := func(s string) map[string]bool {
m := make(map[string]bool)
r := []rune(s)
for i := 0; i+1 < len(r); i++ {
m[string(r[i:i+2])] = true
}
return m
}
ma, mb := bg(a), bg(b)
if len(ma) == 0 && len(mb) == 0 {
return 1.0
}
if len(ma) == 0 || len(mb) == 0 {
return 0.0
}
inter := 0
for k := range ma {
if mb[k] {
inter++
}
}
uni := len(ma) + len(mb) - inter
if uni == 0 {
return 0
}
return float64(inter) / float64(uni)
}
// contentFingerprint 取 content 稳定短指纹(FNV 前 8 hex 位)——同 size 不同内容簇去重。
func contentFingerprint(s string) string {
if s == "" {
return "empty"
}
// FNV-1a 32
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return fmt.Sprintf("%08x", h)
}
func truncateForPattern(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// IsValidPatternName pattern 命名键合法性闸门(t_639c5a03,写入前最后防线)。
// 合法形态:
// - tool_combo:<valid-tool>(+<valid-tool>)* —— 每个成员过 isValidTool
// - workflow:cluster-<N>-<fingerprint>
//
// 其余("tool_combo:.md"、"tool_combo:2026+23" 等垃圾键)一律拒绝,
// UpsertPatterns 拒写 —— 即使调用方判据再退化,垃圾也进不了 graph_nodes。
func IsValidPatternName(name string) bool {
i := strings.IndexByte(name, ':')
if i <= 0 {
return false
}
body := name[i+1:]
if body == "" {
return false
}
switch name[:i] {
case "tool_combo":
parts := strings.Split(body, "+")
if len(parts) < 2 {
return false // 「组合」语义:单成员键拒写(二轮)
}
for _, t := range parts {
// 写入闸 = 白名单唯一来源 + 形态检查(通用词/hash/数值/目录碎片黑名单)。
if !knownTools[t] || !isValidTool(t) {
return false
}
}
return true
case "workflow":
return strings.HasPrefix(body, "cluster-")
}
return false
}