A10(t_d618dc7c): 补 human_useful_count 读侧接线 + 真实 LanceDB e2e 闸门

读侧静默 no-op 修复(t_4c810e02 同类:写进库但读不回):
- lancedb_ipc.go 读路径 unmarshal 结构体缺 human_useful_count 字段,
  导致 /recall、/memories 读回恒 null(列真实写入但读侧解包丢弃,无报错)。
  补 4 处:SearchWithTruncation 结构体+输出映射、Scan 结构体+输出映射、
  GetSkillCandidates(mapInt)、新增 mapIntPtr 可空整数映射(NULL≠0)。
- ScanAll/embedded-model 路径经 models.MemoryRecord 已带字段,无需改。

新增 human_useful_count_e2e_test.go(真实 sidecar 写→读回闸门,
仿 bitemporal_e2e 模式,未设 ZHIYI_HUC_E2E_SOCK 自动 skip):
- AC#4 读非空:存量行读回 NULL(nil);
- AC#5 同步+反例:人工 useful×2⇒huc=2/useful=2;机器 dedup 路径不污染 huc;
  人工 not-useful 只动 human_not_useful。

验证:go build 通过 + 全 storage 套件绿;真实 sidecar e2e 全绿
(AC4 读回 nil / AC5 huc=2 useful=2 / 反例隔离成立)。

git show --stat 212624a4341fac
This commit is contained in:
小唯
2026-10-19 21:28:33 +08:00
parent a5bc27c3a4
commit 79019bfc40
2 changed files with 162 additions and 0 deletions
@@ -0,0 +1,137 @@
package storage_test
// human_useful_count_e2e_test.go — t_d618dc7c A10 人工有用列端到端回归闸门
//
// 为什么必须存在(防重犯 t_4c810e02 静默 no-op):加了列但 Go 读侧解包缺字段 / 输出映射缺行
// ⇒ 列真实写进 LanceDB,但 /recall、/memories 读出来恒 null(无报错、数值逐位不变)。
// 本测试经**真实 sidecar(Rust LanceDB)写→读回**,断言 human_useful_count 非空且等于写入值:
//
// AC#4 读非空:InsertMemory 后 Search 读回,从未人工点过 = nil;IncrementUseful×2 = 2。
// AC#5 反例 :机器 dedup/降权路径(IncrementDedupDownweight)跑后 human_useful_count 不变;
// 人工 not-useful(IncrementHumanNotUseful)只动 human_not_useful_count,不动本列。
//
// 跑法(需先起 sidecar;未设环境变量自动跳过,可安全留在 CI):
// zhiyi-consolidate --mode socket --socket /tmp/mw-huc.sock --data-dir /tmp/mw-hucdata
// ZHIYI_HUC_E2E_SOCK=/tmp/mw-huc.sock go test -run TestHumanUsefulCountE2E -v ./internal/storage/
import (
"os"
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
func TestHumanUsefulCountE2E(t *testing.T) {
sock := os.Getenv("ZHIYI_HUC_E2E_SOCK")
if sock == "" {
t.Skip("ZHIYI_HUC_E2E_SOCK 未设置,跳过(需要 sidecar 在跑)")
}
const ns = "huc-go-e2e"
rc := storage.NewRustLanceDBClient(sock, nil)
vec := make([]float32, 1024)
for i := range vec {
vec[i] = 0.05
}
now := time.Now().UTC()
// 读回:用 ScanAll(embedded model,useful_count 与 human_useful_count 都在),
// Search 路径不投影 useful_count(历史缺口,非本卡范围)⇒ 断言两者同步得走 ScanAll。
read := func(id string) *models.MemoryRecord {
rows, err := rc.ScanAll()
if err != nil {
t.Fatalf("ScanAll 失败: %v", err)
}
for i := range rows {
if rows[i].ID == id {
return &rows[i]
}
}
return nil
}
// ── AC#4:读非空 —— 从未人工点过的存量行 human_useful_count 必须读回 nil(NULL,非 0)──
t.Run("AC4_存量行读回NULL", func(t *testing.T) {
m := models.MemoryRecord{
ID: "huc-e2e-null", AgentID: "hermes-a06", Namespace: ns,
Content: "huc-e2e 从未人工点过样本", Category: "test", Vector: vec,
Tier: "normal", Importance: 1.0, QualityScore: 0.5,
Version: 1, Freshness: "fresh", EmbeddingModel: "bge-m3",
Source: "wiki:/tmp/huc-e2e.md", CreatedAt: now, UpdatedAt: now,
}
if err := rc.InsertMemory(m); err != nil {
t.Fatalf("InsertMemory huc-e2e-null 失败: %v", err)
}
got := read("huc-e2e-null")
if got == nil {
t.Fatalf("✗ 读不回 huc-e2e-null(写入链路断)")
}
if got.HumanUsefulCount != nil {
t.Fatalf("✗ AC4: 存量行 human_useful_count 应为 nil(NULL),got=%d(读侧解包/映射未接好或误填 0)", *got.HumanUsefulCount)
}
t.Log("✅ AC4: 存量行 human_useful_count 读回 = nil(NULL≠0 语义成立)")
})
// ── AC#4 + AC#5:人工 useful ×2 ⇒ human_useful_count nil→1→2 且 useful_count 同步 +2 ──
t.Run("AC5_人工useful两次同步+2", func(t *testing.T) {
m := models.MemoryRecord{
ID: "huc-e2e-inc", AgentID: "hermes-a06", Namespace: ns,
Content: "huc-e2e 连续人工点有用样本", Category: "test", Vector: vec,
Tier: "normal", Importance: 1.0, QualityScore: 0.5,
Version: 1, Freshness: "fresh", EmbeddingModel: "bge-m3",
Source: "wiki:/tmp/huc-e2e.md", CreatedAt: now, UpdatedAt: now,
}
if err := rc.InsertMemory(m); err != nil {
t.Fatalf("InsertMemory huc-e2e-inc 失败: %v", err)
}
if err := rc.IncrementUseful("huc-e2e-inc"); err != nil {
t.Fatalf("IncrementUseful #1 失败: %v", err)
}
g1 := read("huc-e2e-inc")
if g1 == nil {
t.Fatalf("读不回 huc-e2e-inc")
}
if g1.HumanUsefulCount == nil || *g1.HumanUsefulCount != 1 {
t.Fatalf("✗ AC5: 第 1 次人工 useful 后 human_useful_count 应 nil→1,got=%v", g1.HumanUsefulCount)
}
if err := rc.IncrementUseful("huc-e2e-inc"); err != nil {
t.Fatalf("IncrementUseful #2 失败: %v", err)
}
g2 := read("huc-e2e-inc")
if g2 == nil {
t.Fatalf("读不回 huc-e2e-inc (#2)")
}
if g2.HumanUsefulCount == nil || *g2.HumanUsefulCount != 2 {
t.Fatalf("✗ AC5: 第 2 次人工 useful 后 human_useful_count 应=2,got=%v(读非空失败=静默 no-op)", g2.HumanUsefulCount)
}
if g2.UsefulCount != 2 {
t.Fatalf("✗ AC5: useful_count 应与人工 useful 同步 =2,got=%d", g2.UsefulCount)
}
t.Logf("✅ AC5: 人工 useful ×2 ⇒ human_useful_count=%d useful_count=%d(同步、读非空)", *g2.HumanUsefulCount, g2.UsefulCount)
// ── 反例:机器 dedup 降权路径跑一次 ⇒ human_useful_count 不变(仍 2)──
if err := rc.IncrementDedupDownweight("huc-e2e-inc"); err != nil {
t.Fatalf("IncrementDedupDownweight 失败: %v", err)
}
g3 := read("huc-e2e-inc")
if g3.HumanUsefulCount == nil || *g3.HumanUsefulCount != 2 {
t.Fatalf("✗ AC5反例: 机器 dedup 路径不得污染 human_useful_count,want 2 got=%v", g3.HumanUsefulCount)
}
if g3.DedupDownweightCount != 1 {
t.Fatalf("机器 dedup 路径 dedup_downweight_count 应 +1,got=%d", g3.DedupDownweightCount)
}
// ── 人工 not-useful ⇒ human_not_useful_count +1,本列不变 ──
if err := rc.IncrementHumanNotUseful("huc-e2e-inc"); err != nil {
t.Fatalf("IncrementHumanNotUseful 失败: %v", err)
}
g4 := read("huc-e2e-inc")
if g4.HumanUsefulCount == nil || *g4.HumanUsefulCount != 2 {
t.Fatalf("✗ AC5反例: 人工 not-useful 不得动 human_useful_count,want 2 got=%v", g4.HumanUsefulCount)
}
if g4.HumanNotUsefulCount != 1 {
t.Fatalf("人工 not-useful 应 human_not_useful_count +1,got=%d", g4.HumanNotUsefulCount)
}
t.Logf("✅ AC5反例: 机器dedup + 人工not-useful 后 human_useful_count 仍=2(隔离成立)")
})
}
+25
View File
@@ -270,6 +270,8 @@ func (rc *RustLanceDBClient) SearchWithTruncation(table string, vector []float32
// 用 *float64 区分「NULL(存量未声明)」与「显式 0.0」。
Confidence *float64 `json:"confidence"`
ConfidenceSource string `json:"confidence_source"`
// A10 (t_d618dc7c):人工有用单独列(可空,NULL=从未人工点过)读侧投影——不补则静默 no-op。
HumanUsefulCount *int `json:"human_useful_count"`
// vector 必须解析:近重复去重(commit near_dup / 蒸馏写入 gate /
// admin/dedup 端点)全部依赖 records 携带向量做 cosine 比较。
// 此前缺失该字段 → Vector 恒为空 → 所有 len(Vector)>0 判定失败 →
@@ -350,6 +352,8 @@ func (rc *RustLanceDBClient) SearchWithTruncation(table string, vector []float32
// 端到端测试才抓得到)。
Confidence: r.Confidence,
ConfidenceSource: r.ConfidenceSource,
// A10 (t_d618dc7c):人工有用单独列输出映射(解包有 + 输出有,缺则 recall 恒 null)
HumanUsefulCount: r.HumanUsefulCount,
// 2026-10-14 (t_763e8701):bi-temporal 三列输出映射(缺 ⇒ 解包有、输出无,recall 拿不到)
ValidFrom: r.ValidFrom,
ValidTo: r.ValidTo,
@@ -411,6 +415,8 @@ func (rc *RustLanceDBClient) Scan(limit int, namespaceFilter string) ([]models.M
// 2026-10-08 (Gap#2):scan 路径同样要投影,否则该路径返回的 confidence 恒 nil
Confidence *float64 `json:"confidence"`
ConfidenceSource string `json:"confidence_source"`
// A10 (t_d618dc7c):人工有用单独列(可空)读侧投影
HumanUsefulCount *int `json:"human_useful_count"`
// 2026-10-14 (t_763e8701):bi-temporal 三列(scan 列表 / export 也需要)
ValidFrom string `json:"valid_from"`
ValidTo string `json:"valid_to"`
@@ -446,6 +452,7 @@ func (rc *RustLanceDBClient) Scan(limit int, namespaceFilter string) ([]models.M
// GA-P0-1 (t_718d8707):scan 读侧接线
NotUsefulCount: int(r.NotUsefulCount), HumanNotUsefulCount: int(r.HumanNotUsefulCount), DedupDownweightCount: int(r.DedupDownweightCount),
Confidence: r.Confidence, ConfidenceSource: r.ConfidenceSource, // 2026-10-08 (Gap#2)
HumanUsefulCount: r.HumanUsefulCount, // A10 (t_d618dc7c):人工有用单独列输出映射
// 2026-10-14 (t_763e8701):bi-temporal 三列输出映射(scan 列表 / export)
ValidFrom: r.ValidFrom,
ValidTo: r.ValidTo,
@@ -1375,6 +1382,22 @@ func mapInt(m map[string]interface{}, key string) int {
}
return 0
}
// mapIntPtr — A10 (t_d618dc7c):可空整数读侧映射(NULL=从未人工点过 ⇒ nil,区别于显式 0)。
// Rust 侧 human_useful_count 为 Option<i64>,serde 序列化后 null ⇒ 此处 v 缺失/nil ⇒ 返回 nil;
// 非 null ⇒ int。缺这层「NULL≠0」区分即静默 no-op(同 mapFloatPtr 之于 confidence)。
func mapIntPtr(m map[string]interface{}, key string) *int {
if v, ok := m[key]; ok {
if v == nil {
return nil // 显式 null(从未人工标记)
}
if f, ok := v.(float64); ok {
i := int(f)
return &i
}
}
return nil
}
func mapBool(m map[string]interface{}, key string) bool {
if v, ok := m[key]; ok {
if b, ok := v.(bool); ok {
@@ -1464,6 +1487,8 @@ func (rc *RustLanceDBClient) GetSkillCandidates(minRecalls, limit int) ([]models
// GA-P0-1 (t_718d8707):读侧接线拆分后的新字段(漏接线 ⇒ 加列也读不出,静默 no-op)
m.HumanNotUsefulCount = mapInt(r, "human_not_useful_count")
m.DedupDownweightCount = mapInt(r, "dedup_downweight_count")
// A10 (t_d618dc7c):人工有用单独列(可空,NULL=从未人工点过)读侧接线
m.HumanUsefulCount = mapIntPtr(r, "human_useful_count")
m.RecallCount = mapInt(r, "recall_count")
// GA-P0-2 (t_c6d41ac6):拆分字段读侧接线
m.RecallSeenCount = mapInt(r, "recall_seen_count")