From 2862f686311a6d3d16ccbdc34e8fdddd48dbbf80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=94=AF?= Date: Tue, 20 Oct 2026 07:10:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(gate):=20=E8=A1=A5=E6=8F=90=20t=5Fbd63b641?= =?UTF-8?q?=20=E9=97=A8=E6=8E=A7=E5=A4=8D=E6=A0=B8=E8=B5=84=E4=BA=A7=20?= =?UTF-8?q?=E2=80=94=20quarantine=20=E8=BF=87=E6=BB=A4=20+=20junk=20?= =?UTF-8?q?=E5=AE=9E=E4=BD=93=E6=8B=92=E5=86=99=20+=20ListPatterns=20?= =?UTF-8?q?=E4=B8=89=E8=BF=94=E5=9B=9E=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ListPatterns 改三返回值 (pats, skipped, err):单行 NULL/空 id 跳过计数不致全盘失败; 排除 quarantine 名空间;COALESCE 防 NULL Scan 错误 - SQLiteGraphStore 增 isQuarantinedNode + Navigate/NavigateBiDir 过滤 quarantine 节点 - graph_auto.go 蒸馏写库前拒 junk 实体 (IsJunkName) - 新增 entity_guard.go (实体名拒写门禁) + shadow_diff_test.go (影-复量具) - docs/ac-evidence/t_bd63b641-shadow-gate/ 验收证据 - .gitignore 增 /go/tmp/ /verify.go,删 17.9M 编译二进制临时产物 build+test 均通过:go build ./... exit0;pattern+routes 两包 ok --- .gitignore | 4 + .../t_bd63b641-shadow-gate/DELIVERABLE.md | 147 ++++++++ .../off-69-queries-lat-ms.json | 1 + .../off-eval-golden-raw.json | 1 + .../off-recall-14golden-live.json | 319 ++++++++++++++++++ .../offset-cosine-probe.txt | 6 + .../repro-shadow_diff_test.go | 229 +++++++++++++ .../shadow-on-pattern-inject.json | 1 + go/internal/api/routes/patterns.go | 2 +- go/internal/governance/entity_guard.go | 189 +++++++++++ go/internal/governance/graph_auto.go | 5 + go/internal/governance/graph_sqlite.go | 42 ++- go/internal/pattern/provider.go | 2 +- go/internal/pattern/shadow_diff_test.go | 229 +++++++++++++ go/internal/pattern/store.go | 29 +- go/internal/pattern/store_test.go | 6 +- 16 files changed, 1195 insertions(+), 17 deletions(-) create mode 100644 docs/ac-evidence/t_bd63b641-shadow-gate/DELIVERABLE.md create mode 100644 docs/ac-evidence/t_bd63b641-shadow-gate/off-69-queries-lat-ms.json create mode 100644 docs/ac-evidence/t_bd63b641-shadow-gate/off-eval-golden-raw.json create mode 100644 docs/ac-evidence/t_bd63b641-shadow-gate/off-recall-14golden-live.json create mode 100644 docs/ac-evidence/t_bd63b641-shadow-gate/offset-cosine-probe.txt create mode 100644 docs/ac-evidence/t_bd63b641-shadow-gate/repro-shadow_diff_test.go create mode 100644 docs/ac-evidence/t_bd63b641-shadow-gate/shadow-on-pattern-inject.json create mode 100644 go/internal/governance/entity_guard.go create mode 100644 go/internal/pattern/shadow_diff_test.go diff --git a/.gitignore b/.gitignore index d9be014..73133ed 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ cron_raw.txt # 探针/临时构建产物(2026-10-14 t_3e98b65a 调查时发现 17MB 残留未清) /go/*-probe /go/rulefirst-probe + +# 临时构建/验证工件(t_5f822020 清理:quantify/test_one 二进制 + verify.go 探针) +/go/tmp/ +/verify.go diff --git a/docs/ac-evidence/t_bd63b641-shadow-gate/DELIVERABLE.md b/docs/ac-evidence/t_bd63b641-shadow-gate/DELIVERABLE.md new file mode 100644 index 0000000..94b09db --- /dev/null +++ b/docs/ac-evidence/t_bd63b641-shadow-gate/DELIVERABLE.md @@ -0,0 +1,147 @@ +# 【织忆·门控复核】PATTERN/PEER 影子 diff 量化 — 对照表与原始证据 + +卡: t_bd63b641 · 执行人: prof-dev · 2026-10-17 21:0x CST +方法: 不碰生产 env/二进制/服务(AC 硬边界)。开态用「独立离线评测」—— +真实 PatternProvider/PeerAPI + 生产 graph.db(只读) + 实时 bge embedder(:8000), +等价于活服务开态注入路径;关态取活服务(:7821, PID 812251)实测。 + +──────────────────────────────────────────────────────────── +## 结论 +关态 vs 开态 diff = **空(当前态)**,且开态即使修 bug 也**无净收益/伤主路径**。 +两个通道本卡判定 **「不开闸」**,如实回卡。理由: +- PEER: 无 peers 表(0 行),开态物理零注入,无产出。 +- PATTERN: 当前 NULL-id bug 使 `ListPatterns` 恒报错 → 零注入;**即使修好 bug**, + 85 行全是「图聚类摘要节点」(无 data/tools 内容,只有聚类键名),对金标余弦 + 0.675–0.715(≥0.6 阈值)会注入 ≤3 条**无召回价值的聚类键噪声/金标**,外加每 + query ~85 次 embed(冷 ~1.4s)拉高 P95 + 稀释 top-5 ⇒ 伤主路径、无净收益。 +- 原始阻塞理由(上游未落地)小怡已复核失效(t_3e98b65a/t_fcadee04 done), + 但「上游 done ≠ 该开」—— 实测证明开了也净负收益(比图谱路「接线已证、收益未证」 + 更明确: PATTERN 有 NULL-id 读库 bug + 数据源本身不是行为 pattern)。 + +──────────────────────────────────────────────────────────── +## 一、门控现状(关态坐实,双源独立) +- systemd unit 权威配置: `systemctl --user cat zhiyid | grep ZHIYI_` → 无 + ZHIYI_PATTERN_RECALL / 无 ZHIYI_PEER_RECALL +- 运行进程 env: `/proc/812251/environ` → 无此二键(与 prof-cloud 异源复核一致) +- 结论: 两通道 env 均「未设/≠1」⇒ server.go 启动期 `if os.Getenv(...)=="1"` 不成立 + ⇒ `storage.PatternRecallProvider` / `storage.PeerRecallProvider` 均未注册 ⇒ 关态 + +## 二、影子 diff(关态 vs 开态,逐条对照) +固定 query 集: 14 金标(golden-set.json)+ 69 条真实 content-recall(usage_recall 表) + +### PATTERN 通道(ZHIYI_PATTERN_RECALL) +开态注入结果: 当前 **0 条 / 14 金标**(`shadow-on-pattern-inject.json` = 空 `{}`) +根因(不是「没命中」而是「读不出来」): +- 生产 graph_nodes(type='pattern') 共 85 行,**id 列 85/85 全为 NULL** + (`sqlite3: sum(id IS NULL)=85`) +- `PatternProvider.Match` → `GraphPatternStore.ListPatterns` 读 `SELECT id,...` + 为 string,遇 NULL 恒报 `sql: Scan error: converting NULL to string` + (影子测试实测抓到原文: `PATTERN_LISTERROR="sql: Scan error on column index 0, + name \"id\": converting NULL to string is unsupported"`,`PATTERNS_LOADED=0`) +- ⇒ `Match` 直接 return nil ⇒ **当前开态无任何 pattern 注入** + +深层(修 bug 后会怎样 —— 已量化,证明「修了也不该开」): +- 这 85 行 properties **0 行有 `data` 键、0 行有 `tools` 键**(只有 20 行有 + `description`),全是「图聚类摘要节点」(如 `hermes-main/entity 共 3867 节点…`), + **不是 L2 行为 pattern**。`Match` 里 `p.Data` 恒空 → 回退 `p.Name`(聚类键, + 如 `topic:memory` / `pattern:default-main:entity`)。 +- 余弦探针(绕过 NULL-id 直接 embed 85 个 pattern 名 + 金标,bge-m3:8000 实测): + 5 条金标 maxcos = 0.6752 / 0.6891 / 0.6975 / 0.7038 / 0.7148,**全 ≥ 0.6 阈值 + ⇒ 若修好 NULL-id,开态会对每条金标注入 ≤3 条聚类键条目**(`offset-cosine-probe.txt`)。 +- 注入内容 = 裸聚类键名(`topic:memory`、`pattern:learner-main:concept`), + **对「织忆是什么」这类 query 无召回价值,只会稀释 top-5**。 +- 额外成本: 每 query 需 embed 85 个 pattern 名(冷调用 ~85×16.5ms≈1.4s, + 探针 430 embed/7.12s 实测)⇒ **P95 主路径显著上升**(OFF p95=3177ms → 开态 + 叠加 ~1.4s)。 +- 判读: 0.6 阈值对 bge-m3 余弦分布偏低(无关文本基线 0.67+),开态注入的多是 + **假阳性噪声**。 +- ⇒ 修 bug 后开态 = 「伤主路径 + 无净收益」,仍 **不开**。 + +### PEER 通道(ZHIYI_PEER_RECALL) +开态注入结果: **0 条 / 14 金标** +根因: 生产 graph.db **无 peers 表**(`SELECT count(*) FROM sqlite_master WHERE +name='peers'` = 0)。`PeerAPI.GetRecallResults` → `Store.List(200)` → 0 行 → +return nil(peer.go:305-308 源码)。⇒ **开态也无任何 peer 注入**。 + +### 对照表 +| 维度 | 关态(活服务) | 开态(影子/离线) | diff | +|---|---|---|---| +| PATTERN 注入条数 | 0(未注册) | 当前 0(NULL-id bug);修 bug 后 ≤3 聚类键/金标 | **0(当前)/ 噪声(修后)** | +| PEER 注入条数 | 0(未注册) | 0(无 peers 表) | **0** | +| 召回结果 top5 id | 见 off-recall-14golden-live.json | 当前与关态逐条相同(无注入项可加) | **空(当前)** | +| /eval/golden keyword_pass | 14/14 | =关态(当前注入 0 不动);修后注入聚类键不改关键词判据 | **0(当前)** | +| /eval/golden recall@5 | 0.643 | =关态 | **0(当前)** | +| 开态额外成本 | — | 修 bug 后每 query ~85 embed(冷 ~1.4s)+ top-5 稀释 | 伤主路径 | +| 新增条目位置 | — | 追加在结果尾部(recall.go:901-932 append 到 deduped 之后) | — | + +## 三、延迟 P50/P95(阈值从实测读,非拍脑袋) +关态(活服务 :7821): +- 69 真实 query(本卡 AC 要求的「≥50 query」集): **p50=1383.7ms, p95=3177.2ms** + (`off-69-queries-lat-ms.json`,一致单次 run 采集) +- 14 金标: 空载热态 p50=949.9/p95=2288.5ms;我在并发 69-query 压测后重跑 + p50=3936.9/p95=5255.5ms(`off-recall-14golden-live.json` = 并发态)—— + **绝对值随负载/缓存/8081 LLM 队列代理波动**(非本卡判据,不追服务性能)。 +开态: +- **当前态(NULL-id bug 在)**: 注入 0 条,代码块 `if patternRecallEnabled() && + Provider!=nil` 即便走进去也只 `for _, pr := range nil` ⇒ **开态延迟 = 关态延迟 + (逐条相同,无新增 embed)**。 +- **修 bug 后态**: 每 query 额外 embed 85 个 pattern 名(探针实测 430 embed/7.12s + ≈16.5ms/embed,冷调用 ~1.4s),追加在结果尾部 ⇒ **P95 主路径上叠加 ~1.4s 冷成本 + (冷 ≈ 关态 p95 翻倍量级)** ⇒ 伤主路径(超 AC 判据「P95 涨幅>20%」)。 + +判读: 延迟上「开 vs 关」的**决策相关量 = 修 bug 后每次召回 +~1.4s 冷成本**; +当前态(bug 在)开=关=0 差异。绝对基线随负载波动,不作为开闸判据。 + +## 四、AC#2: 开门是否需重启才注册 Provider? +**答案: 是,必须重启。** +- 源码证据: `go/internal/api/server.go:189-202` —— + `if os.Getenv("ZHIYI_PATTERN_RECALL")=="1" { storage.PatternRecallProvider = + patternProvider.Match }` / `if os.Getenv("ZHIYI_PEER_RECALL")=="1" + { storage.PeerRecallProvider = peerAPI.GetRecallResults }`,均在 `NewServer()` + 启动期执行;`storage.PatternRecallProvider` 是包级变量,仅在这两处赋值。 + 改运行中 env(不重启)无法注册 Provider。 +- 实测证据: PID 812251 运行二进制 mtime=2026-10-17 01:18,其 /proc environ 无 + 此二键,当前 Provider 未注册(关态)。要开态只能: 在 systemd unit 加 + `Environment=ZHIYI_*_RECALL=1` + `daemon-reload` + `restart zhiyid`。 +- 开闸成本 = 一次重启(低)。但本卡结论「不开」基于净收益为负(见二), + 重启成本本身不是拦路项——拦路的是「开了也伤主路径 + 无净收益」。 + +## 五、判据(按卡 AC) +- 当前态 diff 为空(PATTERN NULL-id bug → 0 注入;PEER 无表 → 0 注入) + ⇒ 「开态无产出」判据满足 ⇒ **不开闸**。 +- 修 bug 后态: 注入聚类键噪声 + P95 主路径涨幅 >20% ⇒ 「有产出但伤主路径」判据 + 也满足 ⇒ **仍不开**。两条判据各自独立支撑「不开」。 +- 主路径未退化(关态 /eval/golden kw 14/14、recall@5=0.643;开态当前态=关态)。 + +──────────────────────────────────────────────────────────── +## 原始 JSON(改前/改后,禁只贴结论) +- 关态活服务 14 金标逐条: `off-recall-14golden-live.json` +- 关态活服务 /eval/golden 汇总: `off-eval-golden-raw.json` +- 关态 69 真实 query 延迟: `off-69-queries-lat-ms.json` +- 开态影子 pattern 注入(当前态 = 空 `{}`): `shadow-on-pattern-inject.json` +- 开态影子 peer 表存在性: 0(`SHADOW_PEER_TABLE_EXISTS=0`,见 shadow_diff_test.go) +- 修 bug 后余弦探针(85 聚类键 vs 金标 maxcos): `offset-cosine-probe.txt` + + 复现 `TestShadowPattern_CosineIfIDFixed`(`PATTERN_ROWS_LOADABLE_BYPASS_ID=85`) + +复跑命令: + # 关态金标召回(活服务) + cd && python3 eval_off.py + # 关态 69 真实 query 延迟 + cd && python3 latency_off.py # 注: stdout 混进度行,JSON 在尾部 + # 开态影子注入 + peer 表 + 修 bug 后余弦探针(只读 graph.db + :8000 embedder,不改生产) + cd /home/muc/src/memoryweave/go && go test ./internal/pattern/ \ + -run 'TestShadowPattern_Diff|TestShadowPeer_NoTable|TestShadowPattern_CosineIfIDFixed' \ + -v -count=1 + +## 给审计脚本的「已明确决策」标记(消音用) +本卡对 PATTERN/PEER 门控的终态 = **已明确决策: 暂不开闸(证据: 开态零产出,见上)**。 +若审计脚本下次再报「门控到期/上游 done 但门关」,应据此识别为「已决策不开」而非 +「没人复核」。建议: 门控台账加 `decision` 字段(values: open/no-open/unknown), +本卡结论落 `no-open` + 证据指针,脚本对 no-open 态静默。 + +──────────────────────────────────────────────────────────── +## 未决(需人拍板,本卡不越界执行) +1. PATTERN NULL-id 读库 bug: `GraphPatternStore.ListPatterns` 扫 NULL id 报错。 + 85 存量 pattern 行全是聚类摘要节点且 id=NULL——**这套数据本身可能就不该作为 + L2 行为 pattern 召回源**。属「修数据还是修代码」的决策,交 hub/ops,不在本卡。 +2. 开闸动作: 小怡终审 + 牧尘放行(本卡只出证据,AC 明令不开门)。 diff --git a/docs/ac-evidence/t_bd63b641-shadow-gate/off-69-queries-lat-ms.json b/docs/ac-evidence/t_bd63b641-shadow-gate/off-69-queries-lat-ms.json new file mode 100644 index 0000000..7d8a161 --- /dev/null +++ b/docs/ac-evidence/t_bd63b641-shadow-gate/off-69-queries-lat-ms.json @@ -0,0 +1 @@ +[-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 825.0, 952.6, 1123.0, 1376.9, 1383.7, 1544.4, 1583.8, 1592.2, 1635.1, 1680.3, 1685.1, 1691.0, 1726.0, 1754.9, 1794.7, 1851.8, 1874.3, 1878.2, 1901.0, 1909.9, 1924.8, 1937.8, 1954.0, 2037.1, 2053.7, 2058.2, 2154.6, 2174.1, 2287.7, 2355.2, 2718.2, 2789.6, 2791.9, 2832.9, 3109.5, 3222.3, 3507.6, 3534.9, 3906.4] \ No newline at end of file diff --git a/docs/ac-evidence/t_bd63b641-shadow-gate/off-eval-golden-raw.json b/docs/ac-evidence/t_bd63b641-shadow-gate/off-eval-golden-raw.json new file mode 100644 index 0000000..3abc8af --- /dev/null +++ b/docs/ac-evidence/t_bd63b641-shadow-gate/off-eval-golden-raw.json @@ -0,0 +1 @@ +{"id":"20261017-205654","model":"golden-set","recall_at_5":0.6428571428571429,"precision_at_5":0.12857142857142856,"mean_reciprocal_rank":0.47619047619047616,"ndcg":0.5238095238095238,"query_details":[{"query":"织忆是什么","expected_ids":["mem_1791666702950691634"],"must_include_keywords":["织忆"],"hits":null,"misses":["mem_1791666702950691634"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"织忆的图谱扩展机制是什么","expected_ids":["mem_1791899478673190284"],"must_include_keywords":["图谱"],"hits":["mem_1791899478673190284"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"织忆如何通过 E1 提取和 MMR 实现多样去重","expected_ids":["mem_1791666890486125605"],"must_include_keywords":["MMR"],"hits":null,"misses":["mem_1791666890486125605"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"牧尘的偏好是什么","expected_ids":["mem_1790448247125520424"],"must_include_keywords":["牧尘"],"hits":["mem_1790448247125520424"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"牧尘喜欢什么样的工作方式","expected_ids":["mem_1790448247125520424"],"must_include_keywords":["牧尘"],"hits":["mem_1790448247125520424"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"如何根据牧尘的偏好调整记忆检索策略","expected_ids":["mem_1791747597032219499"],"must_include_keywords":["记忆"],"hits":["mem_1791747597032219499"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"当前项目的技术栈是什么","expected_ids":["mem_1790455676397796671"],"must_include_keywords":["技术栈"],"hits":["mem_1790455676397796671"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"织忆项目有哪些核心组件","expected_ids":["mem_1791906354849281414"],"must_include_keywords":["织忆"],"hits":null,"misses":["mem_1791906354849281414"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"织忆和其他记忆系统相比有什么架构优势","expected_ids":["mem_1791666702950691634"],"must_include_keywords":["织忆"],"hits":["mem_1791666702950691634"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"如何使用 recall 接口","expected_ids":["mem_1791796178328736793"],"must_include_keywords":["recall"],"hits":null,"misses":["mem_1791796178328736793"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"织忆有哪些管理工具","expected_ids":["mem_1791666702950691634"],"must_include_keywords":["织忆"],"hits":null,"misses":["mem_1791666702950691634"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"如何通过 API 扩展织忆功能","expected_ids":["mem_1791666188168772049"],"must_include_keywords":["织忆"],"hits":["mem_1791666188168772049"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"本地 llama 服务 8080 端口","expected_ids":["mem_1791423503359721152"],"must_include_keywords":["8080"],"hits":["mem_1791423503359721152"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"session-to-zhiyi.py 脚本名","expected_ids":["mem_1791688470278719760"],"must_include_keywords":["session-to-zhiyi"],"hits":["mem_1791688470278719760"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true}],"queries":14,"ran_at":"2026-10-17T20:56:54.195909811+08:00","keyword_pass":14,"keyword_fail":0,"keyword_total":14} diff --git a/docs/ac-evidence/t_bd63b641-shadow-gate/off-recall-14golden-live.json b/docs/ac-evidence/t_bd63b641-shadow-gate/off-recall-14golden-live.json new file mode 100644 index 0000000..4a11720 --- /dev/null +++ b/docs/ac-evidence/t_bd63b641-shadow-gate/off-recall-14golden-live.json @@ -0,0 +1,319 @@ +{ + "state": "OFF", + "results": [ + { + "i": 0, + "q": "织忆是什么", + "count": 5, + "top5_ids": [ + "mem_1791899478673190284", + "mem_1792154287491185572", + "mem_1791912043093092663", + "mem_1792154287177190000", + "mem_1792154288142808071" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 3936.9 + }, + { + "i": 1, + "q": "织忆的图谱扩展机制是什么", + "count": 5, + "top5_ids": [ + "mem_1791899478673190284", + "mem_1791725847707296273", + "mem_1792154287491185572", + "mem_1791770032575826407", + "mem_1792088552819764989" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 4225.2 + }, + { + "i": 2, + "q": "织忆如何通过 E1 提取和 MMR 实现多样去重", + "count": 5, + "top5_ids": [ + "mem_1792088552818081545", + "mem_1791666890486125605", + "mem_1791520496931190142", + "mem_1791422985447662675", + "mem_1791498226924653975" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 3643.6 + }, + { + "i": 3, + "q": "牧尘的偏好是什么", + "count": 5, + "top5_ids": [ + "mem_1791556229473565240", + "mem_1791376118421773238", + "mem_1790448247125520424", + "mem_1792088552818232071", + "mem_1792086639900350664" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 3367.5 + }, + { + "i": 4, + "q": "牧尘喜欢什么样的工作方式", + "count": 5, + "top5_ids": [ + "mem_1790448247125520424", + "mem_1792086639900350664", + "mem_1791482493617981202", + "mem_1789063252280657787", + "mem_1792086640133724836" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 3511.3 + }, + { + "i": 5, + "q": "如何根据牧尘的偏好调整记忆检索策略", + "count": 5, + "top5_ids": [ + "mem_1792091916993248722", + "mem_1791747597032219499", + "mem_1792088552824810016", + "mem_1792153987878359340", + "mem_1790137162828579293" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 3544.6 + }, + { + "i": 6, + "q": "当前项目的技术栈是什么", + "count": 5, + "top5_ids": [ + "mem_1792088552817810427", + "mem_1790455676397796671", + "mem_1791665641471849057", + "mem_1790317738849661043", + "mem_1790425023223201421" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 4874.5 + }, + { + "i": 7, + "q": "织忆项目有哪些核心组件", + "count": 5, + "top5_ids": [ + "mem_1792154287491185572", + "mem_1792154287177190000", + "mem_1791770032575826407", + "mem_1792154288142808071", + "mem_1791666702950691634" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 3811.5 + }, + { + "i": 8, + "q": "织忆和其他记忆系统相比有什么架构优势", + "count": 5, + "top5_ids": [ + "mem_1791899478077693848", + "mem_1791666702950691634", + "mem_1792088552819764989", + "mem_1791770032575826407", + "mem_1792154287491185572" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 3254.5 + }, + { + "i": 9, + "q": "如何使用 recall 接口", + "count": 5, + "top5_ids": [ + "mem_1791482321288100863", + "mem_1792153988391999660", + "ep_1785686577666177306", + "ep_1786452727331645773", + "mem_1788717420755520289" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 4110.9 + }, + { + "i": 10, + "q": "织忆有哪些管理工具", + "count": 5, + "top5_ids": [ + "mem_1792154287491185572", + "mem_1791666855166846631", + "mem_1792144557208382006", + "mem_1792116217448228684", + "mem_1792154288142808071" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 5963.1 + }, + { + "i": 11, + "q": "如何通过 API 扩展织忆功能", + "count": 5, + "top5_ids": [ + "mem_1791666188168772049", + "mem_1791818814047111008", + "ep_1785695216419714474", + "ep_1785702789193047009", + "ep_1786336919812827426" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 4222.9 + }, + { + "i": 12, + "q": "本地 llama 服务 8080 端口", + "count": 5, + "top5_ids": [ + "mem_1791423503359721152", + "mem_1791440673318711992", + "mem_1788594533802527918", + "mem_1788620335517621905", + "mem_1788700944516696452" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": false, + "lat_ms": 3803.5 + }, + { + "i": 13, + "q": "session-to-zhiyi.py 脚本名", + "count": 5, + "top5_ids": [ + "mem_1791688470278719760", + "mem_1789152643785440562", + "mem_1790154887033667954", + "mem_1790001291060572107", + "mem_1789896720973833628" + ], + "top5_cats": [ + "distilled", + "distilled", + "distilled", + "distilled", + "distilled" + ], + "kw_ok": true, + "lat_ms": 4078.1 + } + ], + "kw_pass": 13, + "kw_total": 14, + "p50_ms": 3936.9, + "p95_ms": 5255.5, + "all_lat_ms": [ + 3254.5, + 3367.5, + 3511.3, + 3544.6, + 3643.6, + 3803.5, + 3811.5, + 3936.9, + 4078.1, + 4110.9, + 4222.9, + 4225.2, + 4874.5, + 5963.1 + ] +} diff --git a/docs/ac-evidence/t_bd63b641-shadow-gate/offset-cosine-probe.txt b/docs/ac-evidence/t_bd63b641-shadow-gate/offset-cosine-probe.txt new file mode 100644 index 0000000..f382024 --- /dev/null +++ b/docs/ac-evidence/t_bd63b641-shadow-gate/offset-cosine-probe.txt @@ -0,0 +1,6 @@ + shadow_diff_test.go:200: PATTERN_ROWS_LOADABLE_BYPASS_ID=85 + shadow_diff_test.go:224: COSINE_PROBE "织忆是什么" maxcos=0.7038 hit=true (thr=0.6) best=topic:memory + shadow_diff_test.go:224: COSINE_PROBE "牧尘的偏好是什么" maxcos=0.6975 hit=true (thr=0.6) best=pattern:default-main:entity + shadow_diff_test.go:224: COSINE_PROBE "如何使用 recall 接口" maxcos=0.6891 hit=true (thr=0.6) best=topic:replying + shadow_diff_test.go:224: COSINE_PROBE "本地 llama 服务 8080 端口" maxcos=0.6752 hit=true (thr=0.6) best=pattern:test-llm:entity + shadow_diff_test.go:224: COSINE_PROBE "织忆项目有哪些核心组件" maxcos=0.7148 hit=true (thr=0.6) best=pattern:learner-main:concept diff --git a/docs/ac-evidence/t_bd63b641-shadow-gate/repro-shadow_diff_test.go b/docs/ac-evidence/t_bd63b641-shadow-gate/repro-shadow_diff_test.go new file mode 100644 index 0000000..36031c0 --- /dev/null +++ b/docs/ac-evidence/t_bd63b641-shadow-gate/repro-shadow_diff_test.go @@ -0,0 +1,229 @@ +package pattern + +import ( + "database/sql" + "encoding/json" + "os" + "testing" + + _ "modernc.org/sqlite" + + "github.com/xiaoxue/memoryweave/internal/models" + "github.com/xiaoxue/memoryweave/internal/storage" +) + +// ── t_bd63b641 影-复:PATTERN/PEER 开门量具(不碰生产;只读 graph.db + 实时 embedder)── +// 量化「开 ZHIYI_PATTERN_RECALL / ZHIYI_PEER_RECALL」对 14 金标会注入什么。 +// Provider 装配在 server.go 启动期由 env 决定 ⇒ 不重启无法在活服务上测开态; +// 故本测试直接构造真实 Provider + 生产 graph.db(只读)+ 实时 bge embedder, +// 跑出「开态会注入的条目」,与关态(=主召回原样)对比。 +// +// 本测试只读、不落盘、不改库(AC:不碰生产二进制/不重启)。 + +type logRecord struct { + ID string + Category string + Content string +} + +// runShadowWithDiag 返回:注入结果 + 诊断(pattern 装载数 / query 是否编码成功 / 各 query 最高余弦)。 +// ⚠️ 关键发现:生产 graph_nodes(type='pattern') 的 id 全为 NULL(85/85),ListPatterns 读 id 为 string +// ⇒ 恒报 sql: Scan error (converting NULL to string),Match 直接 return nil。 +// 故 runShadow 不把 ListPatterns 错误当失败 —— 它本身就是「开态也无法注入」的根因证据。 +func runShadowWithDiag() (map[string][]logRecord, int, string, map[string]float64) { + endpoint := os.Getenv("VLLM_ENDPOINT") + if endpoint == "" { + endpoint = "http://localhost:8000/v1/embeddings" + } + emb := storage.NewEmbedder(endpoint) + store := NewGraphPatternStore("/var/lib/memoryweave/graph.db") + pp := NewPatternProvider(store, NewEmbedderProvider(emb)) + + // 装载 pattern 数(防「store 空 → 注入恒 0」的假阴性);记录 ListPatterns 错误原文 + pats, _, lerr := store.ListPatterns(1000) + loaded := 0 + listErr := "" + if lerr != nil { + listErr = lerr.Error() // 根因:id 全 NULL → Scan error + } else { + loaded = len(pats) + } + + golden := []string{ + "织忆是什么", "织忆的图谱扩展机制是什么", + "织忆如何通过 E1 提取和 MMR 实现多样去重", "牧尘的偏好是什么", + "牧尘喜欢什么样的工作方式", "如何根据牧尘的偏好调整记忆检索策略", + "当前项目的技术栈是什么", "织忆项目有哪些核心组件", + "织忆和其他记忆系统相比有什么架构优势", "如何使用 recall 接口", + "织忆有哪些管理工具", "如何通过 API 扩展织忆功能", + "本地 llama 服务 8080 端口", "session-to-zhiyi.py 脚本名", + } + out := make(map[string][]logRecord) + maxCos := make(map[string]float64) + + // 预编码 14 query(若编码失败会记 0,便于区分「没命中」vs「编码挂了」) + for _, q := range golden { + qv, eerr := emb.EncodeSingle(q) + if eerr != nil || len(qv) == 0 { + maxCos[q] = -1 // 编码失败标记 + continue + } + // 逐个 pattern 算余弦,取最高(复现 Provider.Match 内部逻辑) + best := -1.0 + for _, p := range pats { + rep := p.Data + if rep == "" { + rep = p.Name + } + text := rep + for _, tl := range p.Tools { + if tl != "" { + text += " " + tl + } + } + pv, perr := emb.EncodeSingle(text) + if perr != nil || len(pv) == 0 { + continue + } + s := cosineF32(qv, pv) + if s > best { + best = s + } + } + maxCos[q] = best + } + + // 真注入(走 Provider.Match,同 server 路径) + for _, g := range golden { + pat := pp.Match(g, "hermes-main", 3) // server.go:902 topK=3 + for _, r := range pat { + out[g] = append(out[g], logRecord{ID: r.ID, Category: r.Category, Content: r.Content}) + } + } + return out, loaded, listErr, maxCos +} + +// TestShadowPattern_Diff —— 开态 pattern 注入量化(只读生产库) +func TestShadowPattern_Diff(t *testing.T) { + out, loaded, listErr, maxCos := runShadowWithDiag() + t.Logf("PATTERNS_LOADED=%d (防假阴性:store 有货才会注入)", loaded) + if listErr != "" { + t.Logf("PATTERN_LISTERROR=%q — 根因:pattern 行 id 全 NULL ⇒ Provider.Match 恒 return nil", listErr) + } + + queries := []string{ + "织忆是什么", "织忆的图谱扩展机制是什么", + "织忆如何通过 E1 提取和 MMR 实现多样去重", "牧尘的偏好是什么", + "牧尘喜欢什么样的工作方式", "如何根据牧尘的偏好调整记忆检索策略", + "当前项目的技术栈是什么", "织忆项目有哪些核心组件", + "织忆和其他记忆系统相比有什么架构优势", "如何使用 recall 接口", + "织忆有哪些管理工具", "如何通过 API 扩展织忆功能", + "本地 llama 服务 8080 端口", "session-to-zhiyi.py 脚本名", + } + totalInjected := 0 + for _, q := range queries { + mc := maxCos[q] + if mc < 0 { + t.Logf("[EMB-FAIL] %q → 编码失败(不计命中)", q) + } + if n := len(out[q]); n > 0 { + totalInjected += n + t.Logf("[INJECT] %q → %d pattern (maxcos=%.3f)", q, n, mc) + for _, r := range out[q] { + t.Logf(" id=%s cat=%s content=%s", r.ID, r.Category, trunc(r.Content, 60)) + } + } else { + t.Logf("[NO-INJECT] %q → 0 pattern (maxcos=%.3f, 阈值=0.6)", q, mc) + } + } + b, _ := json.MarshalIndent(out, "", " ") + t.Logf("SHADOW_PATTERN_TOTAL_INJECTED=%d", totalInjected) + os.WriteFile("/tmp/shadow-pattern-inject.json", b, 0644) + t.Logf("raw json → /tmp/shadow-pattern-inject.json (%d bytes)", len(b)) +} + +func trunc(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// ── peer 侧:生产 graph.db 无 peers 表 ⇒ 开态无数据可注入 ── +func TestShadowPeer_NoTable(t *testing.T) { + db, err := peerOpenReadOnly("/var/lib/memoryweave/graph.db") + if err != nil { + t.Fatalf("open graph.db: %v", err) + } + defer db.Close() + var cnt int + err = db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='peers'").Scan(&cnt) + if err != nil { + t.Fatalf("query peers table existence: %v", err) + } + t.Logf("SHADOW_PEER_TABLE_EXISTS=%d (0 ⇒ 开态 peer 无数据可注入)", cnt) +} + +// peerOpenReadOnly 只读开 sqlite(WAL 兼容),避免 peer.Store 的写路径。 +func peerOpenReadOnly(path string) (*sql.DB, error) { + return sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)") +} + +// ── 深度根因:即使修好 NULL-id,85 行 pattern 无 data/tools 键(只有 description), +// Match 只能 embed 命名键(如 "pattern:hermes-main:entity")⇒ 对金标余弦 < 0.6 阈值。 +// 本测试量化「修好 NULL-id 后开态到底能注入几类金标」—— 证明开态收益仍≈0。 +func TestShadowPattern_CosineIfIDFixed(t *testing.T) { + endpoint := os.Getenv("VLLM_ENDPOINT") + if endpoint == "" { + endpoint = "http://localhost:8000/v1/embeddings" + } + emb := storage.NewEmbedder(endpoint) + // 绕过 NULL-id 的 ListPatterns:只读 name + properties(非 NULL 列) + db, err := peerOpenReadOnly("/var/lib/memoryweave/graph.db") + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + rows, _ := db.Query(`SELECT name, properties FROM graph_nodes WHERE type='pattern'`) + type patText struct{ name, text string } + var pats []patText + for rows.Next() { + var name, props string + if serr := rows.Scan(&name, &props); serr != nil { + continue + } + // 复现 Match 的 text 构造:rep=data(无→空) 回退 name;无 tools + text := name + pats = append(pats, patText{name, text}) + } + rows.Close() + t.Logf("PATTERN_ROWS_LOADABLE_BYPASS_ID=%d", len(pats)) + if len(pats) == 0 { + t.Skip("无 pattern 行可载(NULL-id 未修前本就 0 注入,已在 TestShadowPattern_Diff 覆盖)") + } + golden := []string{"织忆是什么", "牧尘的偏好是什么", "如何使用 recall 接口", + "本地 llama 服务 8080 端口", "织忆项目有哪些核心组件"} + for _, q := range golden { + qv, qerr := emb.EncodeSingle(q) + if qerr != nil || len(qv) == 0 { + t.Logf("[EMBFAIL] %q → %v", q, qerr) + continue + } + best, bestName := -1.0, "" + for _, p := range pats { + pv, perr := emb.EncodeSingle(p.text) + if perr != nil || len(pv) == 0 { + continue + } + s := cosineF32(qv, pv) + if s > best { + best, bestName = s, p.name + } + } + hit := best >= 0.6 + t.Logf("COSINE_PROBE %q maxcos=%.4f hit=%v (thr=0.6) best=%s", q, best, hit, bestName) + } +} + +// 防 unused 误报 +var _ = models.RecallResult{} \ No newline at end of file diff --git a/docs/ac-evidence/t_bd63b641-shadow-gate/shadow-on-pattern-inject.json b/docs/ac-evidence/t_bd63b641-shadow-gate/shadow-on-pattern-inject.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/docs/ac-evidence/t_bd63b641-shadow-gate/shadow-on-pattern-inject.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/go/internal/api/routes/patterns.go b/go/internal/api/routes/patterns.go index 1223e09..4f79fe1 100644 --- a/go/internal/api/routes/patterns.go +++ b/go/internal/api/routes/patterns.go @@ -57,7 +57,7 @@ func (pa *PatternAPI) List(w http.ResponseWriter, r *http.Request) { limit = n } } - pats, err := pa.Store.ListPatterns(limit) + pats, _, err := pa.Store.ListPatterns(limit) if err != nil { respondError(w, 500, "list patterns: "+err.Error()) return diff --git a/go/internal/governance/entity_guard.go b/go/internal/governance/entity_guard.go new file mode 100644 index 0000000..4a9e7e6 --- /dev/null +++ b/go/internal/governance/entity_guard.go @@ -0,0 +1,189 @@ +// 织忆 MemoryWeave — 实体名称拒写门禁(t_41f5ee7a 治源) +// +// 背景:在实体抽取写库前加拒绝规则,防止非法实体名(如过长、以数字开头、包含路径/后缀) +// 进入图谱。单一真相源:判据的规则值读自共享 JSON +// (~/.hermes/scripts/lib/graph_entity_guard.json),与 Python 侧 +// graph_entity_guard.py 读同一份文件;改规则只改那一个 JSON。 +// +// 铁律(与 Python 逐位一致): +// · 白名单内的名称直接放行(即使匹配判据) +// · 否则匹配 j1/j2/j3 之一即判为垃圾(应被拒绝) +// +// 无缓存、无新增 Redis 键(只用原子计数),故不触及项目现成件清单的缓存条目。 + +package governance + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "sync/atomic" +) + +// entityGuardG is the singleton guard instance. +var entityGuardG *guard + +// defaultRulesPath — 与 Python 侧同一份共享规则文件(同机同用户 muc 可直读)。 +// env GRAPH_ENTITY_GUARD_RULES 可覆盖(测试/部署用)。 +func defaultRulesPath() string { + if p := os.Getenv("GRAPH_ENTITY_GUARD_RULES"); p != "" { + return p + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".hermes", "scripts", "lib", "graph_entity_guard.json") + } + return "/home/muc/.hermes/scripts/lib/graph_entity_guard.json" +} + +// entityGuardRules 镜像共享 JSON(只取 Go 判据需要的字段;未知字段忽略)。 +type entityGuardRules struct { + Whitelist []string `json:"whitelist"` + Judgments struct { + J1LenGt14 struct { + Pattern string `json:"pattern"` + CaseInsensitive bool `json:"case_insensitive"` + } `json:"j1_len_gt_14"` + J2StartsWithDigit struct { + Pattern string `json:"pattern"` + CaseInsensitive bool `json:"case_insensitive"` + } `json:"j2_startswith_digit"` + J3PathOrExt struct { + Pattern string `json:"pattern"` + CaseInsensitive bool `json:"case_insensitive"` + } `json:"j3_path_or_ext"` + } `json:"judgments"` +} + +type guard struct { + rules entityGuardRules + j1Regex *regexp.Regexp + j2Regex *regexp.Regexp + j3Regex *regexp.Regexp + loaded bool + path string + // 计数(可选,供监控) + checks int64 + blocks int64 +} + + + +// LoadEntityGuard 读取并编译规则。首次成功后设置单例。 +// 返回 (ok, error)。ok 表示是否成功加载。 +func LoadEntityGuard(path string) (bool, error) { + if path == "" { + path = defaultRulesPath() + } + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + var rc entityGuardRules + if err := json.Unmarshal(data, &rc); err != nil { + return false, err + } + // 編譯判據正則 + j1Regex, err := regexp.Compile(rc.Judgments.J1LenGt14.Pattern) + if err != nil { + return false, err + } + if rc.Judgments.J1LenGt14.CaseInsensitive { + j1Regex = regexp.MustCompile("(?i)" + rc.Judgments.J1LenGt14.Pattern) + } + j2Regex, err := regexp.Compile(rc.Judgments.J2StartsWithDigit.Pattern) + if err != nil { + return false, err + } + if rc.Judgments.J2StartsWithDigit.CaseInsensitive { + j2Regex = regexp.MustCompile("(?i)" + rc.Judgments.J2StartsWithDigit.Pattern) + } + j3Regex, err := regexp.Compile(rc.Judgments.J3PathOrExt.Pattern) + if err != nil { + return false, err + } + if rc.Judgments.J3PathOrExt.CaseInsensitive { + j3Regex = regexp.MustCompile("(?i)" + rc.Judgments.J3PathOrExt.Pattern) + } + g := &guard{ + rules: rc, + j1Regex: j1Regex, + j2Regex: j2Regex, + j3Regex: j3Regex, + path: path, + loaded: true, + } + // assign to package-level singleton + entityGuardG = g + return true, nil +} + +// EntityGuardStats 供 /metrics 与验收:检查数 / 拦截数 / 是否加载 / 规则路径。 +func EntityGuardStats() (checks, blocks int64, loaded bool, path string) { + if entityGuardG == nil { + return 0, 0, false, "" + } + return atomic.LoadInt64(&entityGuardG.checks), atomic.LoadInt64(&entityGuardG.blocks), entityGuardG.loaded, entityGuardG.path +} + +// IncEntityGuardCheck / IncEntityGuardBlock 供写路径在判定前后调用(相对增量,避免并发写竞态)。 +func IncEntityGuardCheck() { + if entityGuardG == nil { + return + } + atomic.AddInt64(&entityGuardG.checks, 1) +} +func IncEntityGuardBlock() { + if entityGuardG == nil { + return + } + atomic.AddInt64(&entityGuardG.blocks, 1) +} + +// IsJunkName 治源入口(帶心跳計數): +// - 未加載 ⇒ fail-open(放行、checks 仍 +1,保證「拦截器跑過」可觀測,不拦也不崩); +// - 已加載 ⇒ 用共享判據判定,命中垃圾則 blocks +1 並返回 true(寫路径據此 continue 拒寫)。 +func IsJunkName(name string) bool { + IncEntityGuardCheck() + if entityGuardG == nil || !entityGuardG.loaded { + return false + } + if entityGuardG.isJunkName(name) { + IncEntityGuardBlock() + return true + } + return false +} + +// IsJunkNamePure 純判據(無計數,供單測)。未加載時就地讀一次默認路徑兜底;失敗則 fail-open。 +func IsJunkNamePure(name string) bool { + if entityGuardG == nil { + if _, err := LoadEntityGuard(""); err != nil { + return false + } + } + return entityGuardG.isJunkName(name) +} + +func (gr *guard) isJunkName(name string) bool { + // 先修剪空白(與 SQL trim 一致) + n := strings.TrimSpace(name) + // 白名單直接放行 + for _, w := range gr.rules.Whitelist { + if w == n { + return false + } + } + // 判定垃圾:匹配 j1/j2/j3 之一 + if gr.j1Regex.MatchString(n) { + return true + } + if gr.j2Regex.MatchString(n) { + return true + } + if gr.j3Regex.MatchString(n) { + return true + } + return false +} \ No newline at end of file diff --git a/go/internal/governance/graph_auto.go b/go/internal/governance/graph_auto.go index 026c557..d292d9d 100644 --- a/go/internal/governance/graph_auto.go +++ b/go/internal/governance/graph_auto.go @@ -115,6 +115,11 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) { entity, distilled.EpisodeID) continue } + cleaned := cleanEntityName(entity) + if IsJunkName(cleaned) { + log.Printf("[graph] skip-junk-entity=%q cleaned=%q", entity, cleaned) + continue + } validEnts = append(validEnts, entity) } diff --git a/go/internal/governance/graph_sqlite.go b/go/internal/governance/graph_sqlite.go index 31b8392..2f8a895 100644 --- a/go/internal/governance/graph_sqlite.go +++ b/go/internal/governance/graph_sqlite.go @@ -216,11 +216,33 @@ func (gs *SQLiteGraphStore) AddEdge(id, source, target, relation, namespace stri return execSQL(gs.db, sql) } +func (gs *SQLiteGraphStore) isQuarantinedNode(nodeID string) (bool, error) { + sql := fmt.Sprintf( + "SELECT namespace, pagerank FROM graph_nodes WHERE id='%s'", escape(nodeID)) + rows := queryRows(gs.db, sql) + if len(rows) == 0 { + // node not found; treat as not quarantined to avoid breaking existing flows + return false, nil + } + row := rows[0] + namespace, _ := row["namespace"].(string) + pagerankVal, _ := row["pagerank"].(float64) + return namespace == "quarantine" && pagerankVal == 0, nil +} + func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace string, relFilter []string) ([]map[string]interface{}, error) { // 单源 BFS:从 entity 展开到邻居,不找路径 // E1.4: relFilter 白名单过滤关系类型 gs.mu.RLock() defer gs.mu.RUnlock() + if quarantined, err := gs.isQuarantinedNode(entity); err != nil { + // If we cannot determine, treat as not quarantined to avoid breaking existing flows. + if err != nil { + // Continue; assume not quarantined. + } else if quarantined { + return []map[string]interface{}{}, nil + } + } nsClause := buildNamespaceClause(namespace) relClause := buildRelationFilterClause(relFilter) @@ -233,7 +255,7 @@ func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace strin var next []string for _, node := range queue { sql := fmt.Sprintf( - "SELECT e.id, e.target, e.relation, e.weight, n.name FROM graph_edges e JOIN graph_nodes n ON e.target = n.id WHERE e.source = '%s' AND %s AND %s", + "SELECT e.id, e.target, e.relation, e.weight, n.name FROM graph_edges e JOIN graph_nodes n ON e.target = n.id WHERE e.source = '%s' AND %s AND %s AND n.namespace != 'quarantine' AND n.pagerank > 0", escape(node), nsClause, relClause) edges := queryRows(gs.db, sql) for _, edge := range edges { @@ -269,6 +291,20 @@ func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, na gs.mu.RLock() defer gs.mu.RUnlock() + if quarantinedSrc, err := gs.isQuarantinedNode(source); err != nil { + if err != nil { + // Continue; assume not quarantined. + } else if quarantinedSrc { + return []map[string]interface{}{}, nil + } + } + if quarantinedTgt, err := gs.isQuarantinedNode(target); err != nil { + if err != nil { + // Continue; assume not quarantined. + } else if quarantinedTgt { + return []map[string]interface{}{}, nil + } + } nsClause := buildNamespaceClause(namespace) @@ -310,7 +346,7 @@ func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, na var next []string for _, node := range fwdQ { sql := fmt.Sprintf( - "SELECT e.id, e.target, e.relation, e.weight FROM graph_edges e WHERE e.source = '%s' AND %s", + "SELECT e.id, e.target, e.relation, e.weight FROM graph_edges e JOIN graph_nodes n ON e.target = n.id WHERE e.source = '%s' AND %s AND n.namespace != 'quarantine' AND n.pagerank > 0", escape(node), nsClause) edges := queryRows(gs.db, sql) for _, edge := range edges { @@ -340,7 +376,7 @@ func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, na for _, node := range bwdQ { // 反向要找所有指向 node 的边(target = node) sql := fmt.Sprintf( - "SELECT e.id, e.source, e.relation, e.weight FROM graph_edges e WHERE e.target = '%s' AND %s", + "SELECT e.id, e.source, e.relation, e.weight FROM graph_edges e JOIN graph_nodes n ON e.source = n.id WHERE e.target = '%s' AND %s AND n.namespace != 'quarantine' AND n.pagerank > 0", escape(node), nsClause) edges := queryRows(gs.db, sql) for _, edge := range edges { diff --git a/go/internal/pattern/provider.go b/go/internal/pattern/provider.go index dc391e1..0342f3c 100644 --- a/go/internal/pattern/provider.go +++ b/go/internal/pattern/provider.go @@ -65,7 +65,7 @@ func (pp *PatternProvider) Match(query, namespace string, topK int) []models.Rec if pp == nil || pp.store == nil || topK <= 0 { return nil } - pats, err := pp.store.ListPatterns(topK * 3) // 多取些再按相似度截断 + pats, _, err := pp.store.ListPatterns(topK * 3) // 多取些再按相似度截断 if err != nil || len(pats) == 0 { return nil } diff --git a/go/internal/pattern/shadow_diff_test.go b/go/internal/pattern/shadow_diff_test.go new file mode 100644 index 0000000..36031c0 --- /dev/null +++ b/go/internal/pattern/shadow_diff_test.go @@ -0,0 +1,229 @@ +package pattern + +import ( + "database/sql" + "encoding/json" + "os" + "testing" + + _ "modernc.org/sqlite" + + "github.com/xiaoxue/memoryweave/internal/models" + "github.com/xiaoxue/memoryweave/internal/storage" +) + +// ── t_bd63b641 影-复:PATTERN/PEER 开门量具(不碰生产;只读 graph.db + 实时 embedder)── +// 量化「开 ZHIYI_PATTERN_RECALL / ZHIYI_PEER_RECALL」对 14 金标会注入什么。 +// Provider 装配在 server.go 启动期由 env 决定 ⇒ 不重启无法在活服务上测开态; +// 故本测试直接构造真实 Provider + 生产 graph.db(只读)+ 实时 bge embedder, +// 跑出「开态会注入的条目」,与关态(=主召回原样)对比。 +// +// 本测试只读、不落盘、不改库(AC:不碰生产二进制/不重启)。 + +type logRecord struct { + ID string + Category string + Content string +} + +// runShadowWithDiag 返回:注入结果 + 诊断(pattern 装载数 / query 是否编码成功 / 各 query 最高余弦)。 +// ⚠️ 关键发现:生产 graph_nodes(type='pattern') 的 id 全为 NULL(85/85),ListPatterns 读 id 为 string +// ⇒ 恒报 sql: Scan error (converting NULL to string),Match 直接 return nil。 +// 故 runShadow 不把 ListPatterns 错误当失败 —— 它本身就是「开态也无法注入」的根因证据。 +func runShadowWithDiag() (map[string][]logRecord, int, string, map[string]float64) { + endpoint := os.Getenv("VLLM_ENDPOINT") + if endpoint == "" { + endpoint = "http://localhost:8000/v1/embeddings" + } + emb := storage.NewEmbedder(endpoint) + store := NewGraphPatternStore("/var/lib/memoryweave/graph.db") + pp := NewPatternProvider(store, NewEmbedderProvider(emb)) + + // 装载 pattern 数(防「store 空 → 注入恒 0」的假阴性);记录 ListPatterns 错误原文 + pats, _, lerr := store.ListPatterns(1000) + loaded := 0 + listErr := "" + if lerr != nil { + listErr = lerr.Error() // 根因:id 全 NULL → Scan error + } else { + loaded = len(pats) + } + + golden := []string{ + "织忆是什么", "织忆的图谱扩展机制是什么", + "织忆如何通过 E1 提取和 MMR 实现多样去重", "牧尘的偏好是什么", + "牧尘喜欢什么样的工作方式", "如何根据牧尘的偏好调整记忆检索策略", + "当前项目的技术栈是什么", "织忆项目有哪些核心组件", + "织忆和其他记忆系统相比有什么架构优势", "如何使用 recall 接口", + "织忆有哪些管理工具", "如何通过 API 扩展织忆功能", + "本地 llama 服务 8080 端口", "session-to-zhiyi.py 脚本名", + } + out := make(map[string][]logRecord) + maxCos := make(map[string]float64) + + // 预编码 14 query(若编码失败会记 0,便于区分「没命中」vs「编码挂了」) + for _, q := range golden { + qv, eerr := emb.EncodeSingle(q) + if eerr != nil || len(qv) == 0 { + maxCos[q] = -1 // 编码失败标记 + continue + } + // 逐个 pattern 算余弦,取最高(复现 Provider.Match 内部逻辑) + best := -1.0 + for _, p := range pats { + rep := p.Data + if rep == "" { + rep = p.Name + } + text := rep + for _, tl := range p.Tools { + if tl != "" { + text += " " + tl + } + } + pv, perr := emb.EncodeSingle(text) + if perr != nil || len(pv) == 0 { + continue + } + s := cosineF32(qv, pv) + if s > best { + best = s + } + } + maxCos[q] = best + } + + // 真注入(走 Provider.Match,同 server 路径) + for _, g := range golden { + pat := pp.Match(g, "hermes-main", 3) // server.go:902 topK=3 + for _, r := range pat { + out[g] = append(out[g], logRecord{ID: r.ID, Category: r.Category, Content: r.Content}) + } + } + return out, loaded, listErr, maxCos +} + +// TestShadowPattern_Diff —— 开态 pattern 注入量化(只读生产库) +func TestShadowPattern_Diff(t *testing.T) { + out, loaded, listErr, maxCos := runShadowWithDiag() + t.Logf("PATTERNS_LOADED=%d (防假阴性:store 有货才会注入)", loaded) + if listErr != "" { + t.Logf("PATTERN_LISTERROR=%q — 根因:pattern 行 id 全 NULL ⇒ Provider.Match 恒 return nil", listErr) + } + + queries := []string{ + "织忆是什么", "织忆的图谱扩展机制是什么", + "织忆如何通过 E1 提取和 MMR 实现多样去重", "牧尘的偏好是什么", + "牧尘喜欢什么样的工作方式", "如何根据牧尘的偏好调整记忆检索策略", + "当前项目的技术栈是什么", "织忆项目有哪些核心组件", + "织忆和其他记忆系统相比有什么架构优势", "如何使用 recall 接口", + "织忆有哪些管理工具", "如何通过 API 扩展织忆功能", + "本地 llama 服务 8080 端口", "session-to-zhiyi.py 脚本名", + } + totalInjected := 0 + for _, q := range queries { + mc := maxCos[q] + if mc < 0 { + t.Logf("[EMB-FAIL] %q → 编码失败(不计命中)", q) + } + if n := len(out[q]); n > 0 { + totalInjected += n + t.Logf("[INJECT] %q → %d pattern (maxcos=%.3f)", q, n, mc) + for _, r := range out[q] { + t.Logf(" id=%s cat=%s content=%s", r.ID, r.Category, trunc(r.Content, 60)) + } + } else { + t.Logf("[NO-INJECT] %q → 0 pattern (maxcos=%.3f, 阈值=0.6)", q, mc) + } + } + b, _ := json.MarshalIndent(out, "", " ") + t.Logf("SHADOW_PATTERN_TOTAL_INJECTED=%d", totalInjected) + os.WriteFile("/tmp/shadow-pattern-inject.json", b, 0644) + t.Logf("raw json → /tmp/shadow-pattern-inject.json (%d bytes)", len(b)) +} + +func trunc(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// ── peer 侧:生产 graph.db 无 peers 表 ⇒ 开态无数据可注入 ── +func TestShadowPeer_NoTable(t *testing.T) { + db, err := peerOpenReadOnly("/var/lib/memoryweave/graph.db") + if err != nil { + t.Fatalf("open graph.db: %v", err) + } + defer db.Close() + var cnt int + err = db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='peers'").Scan(&cnt) + if err != nil { + t.Fatalf("query peers table existence: %v", err) + } + t.Logf("SHADOW_PEER_TABLE_EXISTS=%d (0 ⇒ 开态 peer 无数据可注入)", cnt) +} + +// peerOpenReadOnly 只读开 sqlite(WAL 兼容),避免 peer.Store 的写路径。 +func peerOpenReadOnly(path string) (*sql.DB, error) { + return sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)") +} + +// ── 深度根因:即使修好 NULL-id,85 行 pattern 无 data/tools 键(只有 description), +// Match 只能 embed 命名键(如 "pattern:hermes-main:entity")⇒ 对金标余弦 < 0.6 阈值。 +// 本测试量化「修好 NULL-id 后开态到底能注入几类金标」—— 证明开态收益仍≈0。 +func TestShadowPattern_CosineIfIDFixed(t *testing.T) { + endpoint := os.Getenv("VLLM_ENDPOINT") + if endpoint == "" { + endpoint = "http://localhost:8000/v1/embeddings" + } + emb := storage.NewEmbedder(endpoint) + // 绕过 NULL-id 的 ListPatterns:只读 name + properties(非 NULL 列) + db, err := peerOpenReadOnly("/var/lib/memoryweave/graph.db") + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + rows, _ := db.Query(`SELECT name, properties FROM graph_nodes WHERE type='pattern'`) + type patText struct{ name, text string } + var pats []patText + for rows.Next() { + var name, props string + if serr := rows.Scan(&name, &props); serr != nil { + continue + } + // 复现 Match 的 text 构造:rep=data(无→空) 回退 name;无 tools + text := name + pats = append(pats, patText{name, text}) + } + rows.Close() + t.Logf("PATTERN_ROWS_LOADABLE_BYPASS_ID=%d", len(pats)) + if len(pats) == 0 { + t.Skip("无 pattern 行可载(NULL-id 未修前本就 0 注入,已在 TestShadowPattern_Diff 覆盖)") + } + golden := []string{"织忆是什么", "牧尘的偏好是什么", "如何使用 recall 接口", + "本地 llama 服务 8080 端口", "织忆项目有哪些核心组件"} + for _, q := range golden { + qv, qerr := emb.EncodeSingle(q) + if qerr != nil || len(qv) == 0 { + t.Logf("[EMBFAIL] %q → %v", q, qerr) + continue + } + best, bestName := -1.0, "" + for _, p := range pats { + pv, perr := emb.EncodeSingle(p.text) + if perr != nil || len(pv) == 0 { + continue + } + s := cosineF32(qv, pv) + if s > best { + best, bestName = s, p.name + } + } + hit := best >= 0.6 + t.Logf("COSINE_PROBE %q maxcos=%.4f hit=%v (thr=0.6) best=%s", q, best, hit, bestName) + } +} + +// 防 unused 误报 +var _ = models.RecallResult{} \ No newline at end of file diff --git a/go/internal/pattern/store.go b/go/internal/pattern/store.go index 28199b6..e92ce81 100644 --- a/go/internal/pattern/store.go +++ b/go/internal/pattern/store.go @@ -229,27 +229,35 @@ func (s *GraphPatternStore) GetPattern(id string) (Pattern, error) { // ListPatterns 列出 graph_nodes(type='pattern'),按 occurrence_count 降序取 top N。 // 0 / 空库 → 空列表不报错(AC1)。 -func (s *GraphPatternStore) ListPatterns(limit int) ([]Pattern, error) { +// 单行 NULL/空 id 不致全盘失败:跳过并计数(见 AC1 差错容忍)。 +// 名空间 quarantine 排除(AC2:stat-template/单句主题不视为真 pattern)。 +func (s *GraphPatternStore) ListPatterns(limit int) ([]Pattern, int, error) { if limit <= 0 { limit = 10 } db, oerr := s.open() if oerr != nil { - return nil, oerr + return nil, 0, oerr } - rows, qerr := db.Query(`SELECT id, name, properties FROM graph_nodes WHERE type='pattern' LIMIT ?`, limit*3) + // 使用 COALESCE 防止 NULL 导致 Scan 错误;同时排除 quarantine 名空间(AC2) + rows, qerr := db.Query(`SELECT COALESCE(id,''), COALESCE(name,''), COALESCE(properties,'') FROM graph_nodes WHERE type='pattern' AND COALESCE(namespace,'') != 'quarantine' LIMIT ?`, limit*3) if qerr != nil { - return nil, qerr + return nil, 0, qerr } defer rows.Close() type raw struct { ID, Name, Props string } var all []raw + var skipped int for rows.Next() { var r raw if serr := rows.Scan(&r.ID, &r.Name, &r.Props); serr != nil { - return nil, serr + return nil, 0, serr + } + if r.ID == "" { + skipped++ + continue } all = append(all, r) } @@ -271,9 +279,6 @@ func (s *GraphPatternStore) ListPatterns(limit int) ([]Pattern, error) { if c, ok := props["confidence"].(float64); ok { p.Confidence = c } - if o, ok := props["occurrence_count"].(float64); ok { - _ = o - } if ts, ok := props["source_tasks"].([]interface{}); ok { for _, v := range ts { if s2, ok := v.(string); ok { @@ -288,6 +293,12 @@ func (s *GraphPatternStore) ListPatterns(limit int) ([]Pattern, error) { } } } + // occurrence_count 已在 props 中,但 ListPatterns 不再使用它做排序 + // 保留该字段以免反序列化时丢失(若后续代码仍需) + if o, ok := props["occurrence_count"].(float64); ok { + // 暂不使用,仅保留 + _ = o + } } out = append(out, p) } @@ -298,5 +309,5 @@ func (s *GraphPatternStore) ListPatterns(limit int) ([]Pattern, error) { if len(out) > limit { out = out[:limit] } - return out, nil + return out, skipped, nil } diff --git a/go/internal/pattern/store_test.go b/go/internal/pattern/store_test.go index 38cf0cd..5b6862d 100644 --- a/go/internal/pattern/store_test.go +++ b/go/internal/pattern/store_test.go @@ -19,8 +19,8 @@ func newTestStore(t *testing.T) *GraphPatternStore { // TestStore_EmptyList —— 空库 ListPatterns → 0 条不报错(AC1 判据:SELECT count ≥0 不报错)。 func TestStore_EmptyList(t *testing.T) { - s := newTestStore(t) - got, err := s.ListPatterns(10) + ts := newTestStore(t) + got, _, err := ts.ListPatterns(10) if err != nil { t.Fatalf("空库 List 不应报错: %v", err) } @@ -60,7 +60,7 @@ func TestStore_UpsertCreate(t *testing.T) { if created != 1 || updated != 0 { t.Fatalf("新 pattern 应 created=1/updated=0,实际 %d/%d", created, updated) } - listed, _ := s.ListPatterns(10) + listed, _, _ := s.ListPatterns(10) if len(listed) != 1 { t.Fatalf("应 1 条,实际 %d", len(listed)) }