From 1fa9ef68a4c62b47a0d4ac6d2ff55ff19b097e4c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 19:56:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E5=9C=BA=E6=99=AF=E9=94=AE?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96=20+=20=E6=8E=92=E9=99=A4=E6=9C=80?= =?UTF-8?q?=E5=BC=B1=E7=BB=B4=E5=BA=A6=EF=BC=8C=E4=BF=AE=E5=8F=8C=E8=83=9E?= =?UTF-8?q?=E8=83=8E=E4=B8=8E=E7=A9=BA=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现网实测:scenes 表 65 行里有 6 组是同一场面的双胞胎键,最严重的 auto:chan:qq+part:morning 累积到 strength=270、6 个 features、 0 条记忆,日志里被"命中"179 次;孪生的 auto:chan:qq_part:morning 则持有 201 条记忆却有 0 个 features(不参与聚类)。两套特征体系 各活各的,谁也发现不了谁。 病因:键构造不唯一。 - Label() 直接用 "+" 拼接且不过 NormalizeSceneKey,而 EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。 "+" 会被 normalizeSceneSegment 归一成 "_",于是两个字符串都合法, key UNIQUE 约束拦不住。 - Label(2) 会把权重仅 0.2 的 part(时段)挤进场景身份。实测 morning 场景吞掉 evening 指纹:共享 chan:qq 权重 1.0、并集含 part 0.2×2,相似度 1.0/1.4 = 0.714 > joinSceneThreshold 0.5。 这本就是加权 Jaccard 的正常行为,但键名不该写进时段。 - createSceneLocked 的 "#N" 冲突后缀同样会被归一成 "_", 造成 auto:chan:mc:event+topic:mc#2 在库、而写侧归一化后去找 auto:chan:mc:event_topic:mc_2 —— 又一对匹配不上的双胞胎。 修法: - Label 只取权重 ≥ 0.5 的主导特征,结果过 NormalizeSceneKey; 全部特征都弱于门槛时退回最强的一批(宁可名字信息量低,也不能 没有名字 —— 没名字就没有键,场景根本长不出来)。 - createSceneLocked 对 base 再做一次防御性归一化,并在 label 为空 时拒建无名场景。 - 冲突后缀由 "#" 改为 "."('#' 会被归一化,'.' 是白名单字符)。 判据:新增 scene_key_test.go 6 例,参照物在生产代码之外("同一场面 ⇒ 同一个键"这条不变量 + 直查 scenes 表复算行数)。先跑红确认判据 在跑(4 红 1 绿,失败的正是键唯一性/时段污染/证据桶),再改实现。 其中 TestWrittenRefReachableFromItsScene 一开始就是绿的——写侧到读侧 那条路本身是通的,坏的只是键的构造。 记忆 8 包 + agent/core 全绿。 --- docs/zh/scene-memory-fix-plan.md | 116 +++++++++++++++ internal/memory/scene_emerge.go | 70 +++++++-- internal/memory/scene_key_test.go | 238 ++++++++++++++++++++++++++++++ 3 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 docs/zh/scene-memory-fix-plan.md create mode 100644 internal/memory/scene_key_test.go diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md new file mode 100644 index 0000000..7e552c9 --- /dev/null +++ b/docs/zh/scene-memory-fix-plan.md @@ -0,0 +1,116 @@ +# 场景式记忆修复 plan + +> 起点:2026-09-26 生产库实测(`/home/newqqagent/memory/graph.db`)。 +> 现象:65 个场景中 6 组是同一场面的双胞胎键;主力场景 `auto:chan:qq+part:morning` +> strength=270、6 个 features、**0 条记忆**,日志里被"命中"179 次。 +> 分支:`feature/scene-writeback`(从 main 拉出,工作树干净)。 + +--- + +## 根因(三条,逐条修) + +### R1 建键不过归一化(病因) + +`internal/memory/scene_emerge.go:329` + +```go +base := "auto:" + sig.Label(2) // Label 用 "+" 拼接,未归一化 +``` + +而同一层的另外三条路**都**过了归一化: + +| 位置 | 是否归一化 | +|---|---| +| `EnsureScene`(声明建键)`scene_emerge.go:555` | ✅ | +| `effectiveScenes`(写侧挂 ref)`graph.go:1397` | ✅ | +| `RecallByScene`(读侧召回)`scene.go:328` | ✅ | +| **`createSceneLocked`(涌现建键)`scene_emerge.go:329`** | ❌ | + +`normalizeSceneSegment` 把 `+` 归一成 `_`,所以涌现键天生带 `+`、其余三方说 `_`。 +`scenes.key` 虽有 `UNIQUE` 约束,但**两个不同字符串都合法**,拦不住。 + +实测 6 组双胞胎(`REPLACE(key,'+','_')` 后重名): + +``` +auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning (270/0 refs) +auto:chan:mc:event+topic:mc ↔ auto:chan:mc:event_topic:mc +auto:chan:mc:system+topic:mc ↔ auto:chan:mc:system_topic:mc +auto:chan:mc-resident+topic:mc ↔ auto:chan:mc-resident_topic:mc +auto:chan:homeagent-mail-bridge+... ↔ auto:chan:homeagent-mail-bridge_... +auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务 +``` + +### R2 `part`(时段,权重 0.2)污染主键与门槛 + +`Label(2)` 取权重最高的 2 个特征。特征顺序是 +`chan(1.0) → peer(1.0) → tool(0.8) → topic(0.4) → part(0.2)`, +只有当前 2 个强特征不足时 `part` 才会进键——于是出现 +`auto:chan:qq+part:morning` 这种"时段成了场景身份"的名字。 + +**更糟的是 `recordSituationEvidenceLocked` 也用 `Label(2)` 做桶**(`scene_emerge.go:380`), +所以 `part` 不只影响名字,还决定 `minSceneEvidence=2` 这个门槛在哪个桶里计数。 + +实测证据(键名与实际指纹自相矛盾): + +``` +场景键: auto:chan:qq+part:morning strength=270 +实际指纹: [chan:qq part:evening] ← 19:15 命中,evening 却并入 morning +``` + +这不是 bug 触发,是加权 Jaccard 正常工作:共享 `chan:qq`(1.0)、 +并集含 `part`(0.2×2),相似度 1.0/1.4 = 0.714 > `joinSceneThreshold` 0.5。 + +### R3 图整理心跳漏扫 `scenes` + +`detectEntityMerge` 唯一遍历入口 `Recall(nil,nil,1,"")`,其全量路径只查 +`entities`(`graph.go:609`) + `relations`(`graph.go:631`),`scenes` 不在其中。 +所以 R1/R2 造成的双胞胎从 9-15 起无人发现,23 号空转到 strength=270。 + +**R3 是"没被发现"的原因,R1/R2 是病因。** 顺序不能反。 + +--- + +## 步骤 + +### 步骤 1:修 R1 + R2(源头,不碰存量) + +- [ ] `Label` 改名语义:**只取权重 ≥ 阈值的主导特征**,且结果过 `NormalizeSceneKey` +- [ ] `createSceneLocked` 的 `base` 用归一化后的 label +- [ ] `recordSituationEvidenceLocked` 的桶键用同一个归一化 label(R2 的另一半) +- [ ] 判据:先写**红**测试——同一指纹两次建键必须落同一行(现状会落两行) + +### 步骤 2:修 R3(让图整理覆盖全库) + +- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环 + (理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮; + 场景表小且**已有现成的 `situationSimilarity` 加权 Jaccard**,语义更准) +- [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面) +- [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个 + +### 步骤 3:清理现网垃圾场景,让它重新生成 + +用户明确要求:**直接清理,重新生成**(不做保守迁移)。 + +- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照) +- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs` + (**保留 `origin='declared'`**:那些是插件声明的,不是垃圾) +- [ ] 同步清 `situation_evidence` +- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现 +- [ ] 复验:新场景键**不含 `+`**、有 features **且**有 refs + +### 步骤 4:验证与收口 + +- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿 +- [ ] `go build ./...` + 全仓 `go test ./...` +- [ ] 现网观察:日志中场景命中后能查到 refs(非 0) +- [ ] `git_release_check.sh` 无新增红项 + +--- + +## 不做的事(防反复挂账) + +- **不**把 `scenes` 塞进 `memory_merge` 工具:该工具语义是"实体删除 + 关系重定向", + 场景合并需要"特征并集 + refs 重定向 + strength 相加",是另一套操作,硬塞会让工具语义变危险。 +- **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。 +- **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮), + 混进本次修复会让 diff 失焦。单独开条目。 diff --git a/internal/memory/scene_emerge.go b/internal/memory/scene_emerge.go index ce14ea3..3c8dca3 100644 --- a/internal/memory/scene_emerge.go +++ b/internal/memory/scene_emerge.go @@ -132,17 +132,52 @@ func (s Situation) Keys() []string { // Empty 表示指纹里没有任何可判定的信号。 func (s Situation) Empty() bool { return len(s.Features) == 0 } -// Label 用权重最高的少数特征给场景起个可读名字(`chan:qq+tool:qq_get_message`)。 -// 只用于人看,不参与匹配——匹配永远走特征集合。 +// labelFeatureWeight 是参与**场景身份**的最低特征权重。 +// +// 为什么设门槛:part(时段)权重只有 0.2,是场面里最弱的维度—— +// 「在 QQ 上」和「在 QQ 上且是早上」是同一个场面,时段不该把它切成两个。 +// 早期实现直接取 Label(2),只有 chan 一个强特征时 part 必然挤进第二位, +// 于是键名变成 auto:chan:qq+part:morning:既是「时段成了身份」, +// 又让加权 Jaccard 把它当成另一个场面(实测:morning 场景吞掉 evening 指纹, +// 共享 chan:qq 权重 1.0、并集含 part 0.2×2,相似度 1.0/1.4=0.714 > 0.5)。 +const labelFeatureWeight = 0.5 + +// Label 用权重达标的主导特征给场景起个**可读名**(`chan:qq+tool:qq_get_message`)。 +// +// 两条硬约束(缺一就会造出写侧匹配不上的键): +// 1. 只取权重 ≥ labelFeatureWeight 的特征:时段/话题不进身份。 +// 2. 结果**必须过 NormalizeSceneKey**:'+' 会被 normalizeSceneSegment 归一成 +// '_',而 EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。 +// 建键路径漏掉这一步,库中就会并存 auto:chan:qq+part:morning 与 +// auto:chan:qq_part:morning 两个键——key UNIQUE 拦不住(两个不同字符串), +// 于是「有 features 却 0 条记忆」与「有记忆却不参与聚类」两个半死节点并存 +// (生产实测 strength=270 / 6 features / 0 refs 对 strength=1 / 0 / 201)。 func (s Situation) Label(max int) string { if max <= 0 { max = 2 } - keys := s.Keys() - if len(keys) > max { - keys = keys[:max] + var picked []string + for _, f := range s.Features { + if f.Weight() < labelFeatureWeight { + continue + } + picked = append(picked, f.Key()) + if len(picked) >= max { + break + } } - return strings.Join(keys, "+") + if len(picked) == 0 { + // 全部特征都弱于门槛(纯 topic/part 的轮次):退回最强的一批特征, + // 宁可名字信息量低,也不要没有名字——没名字就没有键,场景根本长不出来。 + n := max + if n > len(s.Features) { + n = len(s.Features) + } + for _, f := range s.Features[:n] { + picked = append(picked, f.Key()) + } + } + return NormalizeSceneKey(strings.Join(picked, "+")) } // emergentScene 是一次聚类计算中的场景视图。 @@ -326,7 +361,15 @@ func (g *GraphDB) reinforceSceneLocked(sceneID int64, sig Situation) error { // createSceneLocked 用指纹长出一个新场景(键由主导特征派生,仅作可读名)。 func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { - base := "auto:" + sig.Label(2) + // Label 已保证:过滤弱特征 + 过 NormalizeSceneKey。 + // 这里再过一次防御性归一化:键的唯一性是整个场景层的地基, + // 不能依赖「上游一定调对了 Label」——生产库里已经存在双胞胎键, + // 任何一条新路径再漏归一化就会再生产一批(见 Label 的注释)。 + base := NormalizeSceneKey("auto:" + sig.Label(2)) + if base == "auto:" { + // Label 退化到空(指纹被裁空):不建无主场景,否则所有空指纹会堆进同一行。 + return "", fmt.Errorf("situation label 为空,拒绝建无名场景") + } key := base tx, err := g.db.Begin() @@ -336,6 +379,13 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { defer tx.Rollback() // 键冲突(同一可读名已被占)时加后缀,不合并——真正的合并交给相似度判定。 + // + // ★ 这里加出来的 #N 后缀**必须与原键一样合法**:它会被写进 scenes.key, + // 而写侧(effectiveScenes)与读侧(RecallByScene)都会对它做归一化。 + // '#' 不在 normalizeSceneSegment 的白名单里,会被归一成 '_'—— + // 于是 auto:chan:qq#2 在库里存在,而写侧归一化后去找 auto:chan:qq_2, + // 又是一对匹配不上的双胞胎(生产库已有 auto:chan:mc:event+topic:mc#2 这类)。 + // 所以后缀改用不会触发归一化改写的字符。 for i := 2; ; i++ { var exists int if err := tx.QueryRow(`SELECT COUNT(*) FROM scenes WHERE key = ?`, key).Scan(&exists); err != nil { @@ -344,7 +394,7 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { if exists == 0 { break } - key = fmt.Sprintf("%s#%d", base, i) + key = fmt.Sprintf("%s.%d", base, i) } res, err := tx.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'emergent')`, key) @@ -372,8 +422,8 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { // recordSituationEvidenceLocked 登记一次「同类指纹出现过」,返回累计次数。 // -// 用指纹标签(主导特征)做粗聚类桶,只服务于「首次不建场景」的门槛判定, -// 不参与后续匹配——匹配永远走 EnterScene 的相似度。 +// 用 Label(2) 做粗聚类桶(Label 已过归一化、不含时段),只服务于 +// 「首次不建场景」的门槛判定,不参与后续匹配——匹配永远走 EnterScene 的相似度。 func (g *GraphDB) recordSituationEvidenceLocked(sig Situation) (int, error) { label := sig.Label(2) if _, err := g.db.Exec( diff --git a/internal/memory/scene_key_test.go b/internal/memory/scene_key_test.go new file mode 100644 index 0000000..759054a --- /dev/null +++ b/internal/memory/scene_key_test.go @@ -0,0 +1,238 @@ +package memory + +// 场景键唯一性的判据(修复 R1/R2 的红测试)。 +// +// 背景(生产实测):scenes.key 有 UNIQUE 约束,但同一场面仍裂成两个键—— +// auto:chan:qq+part:morning strength=270 6 features 0 refs +// auto:chan:qq_part:morning strength=1 0 features 201 refs +// 病因:createSceneLocked 用 sig.Label(2) 建键且不过 NormalizeSceneKey, +// 而 EnsureScene / effectiveScenes / RecallByScene 三处都过了。 +// +// 本组测试的判据在**生产代码之外**:期望值是「同一场面 ⇒ 同一个键」这条 +// 不变量,不引用任何被测实现细节。判据自身也做了双向检查: +// 先用「改实现 ⇒ 必须变红」验证过它真的在跑。 + +import ( + "os" + "strings" + "testing" +) + +// sigWithPart 只有 chan + part 两个特征(part 权重 0.2,是最弱维度)。 +func sigWithPart(chanName, part string) Situation { + return NewSituation( + SituationFeature{Kind: "chan", Value: chanName}, + SituationFeature{Kind: "part", Value: part}, + ) +} + +// R1:同一指纹反复出现,键必须唯一,不能每次都造新行。 +func TestSceneKeyIsUniqueForSameSituation(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + sig := mkSig("qq", "", "") + + // 连喂 6 次。minSceneEvidence=2 ⇒ 第 2 次建场景、之后只强化。 + // 要断言的**不是**哪一次 created,而是:全程只允许存在一个键。 + keys := map[string]int{} + for i := 0; i < 6; i++ { + key, _, err := g.EnterScene(sig) + if err != nil { + t.Fatalf("第 %d 次 EnterScene 出错: %v", i+1, err) + } + if key == "" { + continue + } + keys[key]++ + } + + if len(keys) == 0 { + t.Fatal("6 次重复交互后仍未建出场景,与 minSceneEvidence=2 的设计矛盾") + } + if len(keys) > 1 { + t.Fatalf("同一指纹造出了 %d 个不同场景键: %v —— 键构造不唯一", len(keys), keys) + } + if n := len(sceneKeys(t, g)); n != 1 { + t.Fatalf("scenes 表里有 %d 行,应为 1(重复交互不得增殖场景行)", n) + } +} + +// 门槛本身:第 1 次只留足迹、第 2 次建场景。这是 minSceneEvidence 的语义, +// 单独钉住,免得修键时把门槛一起改掉。 +func TestSceneEvidenceThresholdIsTwo(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + sig := mkSig("qq", "", "") + + if key, _, err := g.EnterScene(sig); err != nil { + t.Fatal(err) + } else if key != "" { + t.Fatalf("第 1 次就建了场景 %q,minSceneEvidence=2 失效", key) + } + + key, created, err := g.EnterScene(sig) + if err != nil { + t.Fatal(err) + } + if key == "" || !created { + t.Fatalf("第 2 次应建出新场景(key=%q created=%v)", key, created) + } + + // 第 3 次起只强化,不再新建 + if _, created, err := g.EnterScene(sig); err != nil { + t.Fatal(err) + } else if created { + t.Fatal("第 3 次不该再新建场景") + } +} + +// R1(生产现场形态):带 + 的键与带 _ 的键必须归一到同一个。 +// 这是双胞胎的直接复现:现状下 auto:chan:qq+part:morning 与 +// auto:chan:qq_part:morning 会同时存在于 scenes 表。 +func TestSceneKeyNormalized_NoPlusVersusUnderscore(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 造两次:强特征相同、只有 part 不同(现实中「早上在 QQ」与「晚上在 QQ」) + for _, part := range []string{"morning", "evening"} { + sig := sigWithPart("qq", part) + for i := 0; i < 3; i++ { + if _, _, err := g.EnterScene(sig); err != nil { + t.Fatalf("EnterScene(%s) 出错: %v", part, err) + } + } + } + + keys := sceneKeys(t, g) + if len(keys) == 0 { + t.Fatal("未建出任何场景") + } + for _, k := range keys { + if strings.Contains(k, "+") { + t.Errorf("场景键 %q 含未归一化的 '+';其余三条路径(EnsureScene/"+ + "effectiveScenes/RecallByScene)都用 NormalizeSceneKey,"+ + "只有建键路径漏了 ⇒ 写侧永远匹配不上", k) + } + } +} + +// R2:part(权重 0.2,最弱维度)不该成为场景身份的一部分。 +func TestSceneKeyExcludesWeakPartFeature(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 只有 chan 一个强特征,part 必然挤进 Label(2) 的第二位。 + for i := 0; i < 3; i++ { + if _, _, err := g.EnterScene(sigWithPart("qq", "morning")); err != nil { + t.Fatal(err) + } + } + + keys := sceneKeys(t, g) + if len(keys) == 0 { + t.Fatal("未建出场景") + } + for _, k := range keys { + if strings.Contains(k, "part") { + t.Errorf("场景键 %q 把时段(part, 权重 0.2)写进了身份。"+ + "时段是最弱维度:生产库实测出现「morning」场景吞掉 evening 指纹"+ + "(共享 chan:qq,相似度 1.0/1.4=0.714 > 阈值 0.5)", k) + } + } +} + +// R2 的另一半:part 不该影响「是否建场景」的门槛桶。 +// 同一个场面在 morning 出现两次、evening 出现一次,应该只算两次 +// (minSceneEvidence=2 已满足),而不是按时段分桶各数一次。 +func TestEvidenceBucketIgnoresPart(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 第一轮 morning:只登记足迹,不建场景 + if key, _, err := g.EnterScene(sigWithPart("qq", "morning")); err != nil { + t.Fatal(err) + } else if key != "" { + t.Fatalf("首次不该建场景,却建了 %q", key) + } + + // 第二轮换成 evening:若门槛按 part 分桶,这里就又要再等一次 + key, _, err := g.EnterScene(sigWithPart("qq", "evening")) + if err != nil { + t.Fatal(err) + } + if key == "" { + t.Fatal("同一场面(只差时段)第 2 次仍未建场景 —— 时段把证据桶拆开了") + } +} + +// R1 完整复现:模拟生产库里「写入侧归一化、建键侧不归一化」的分裂。 +// 断言:写侧挂的记忆,最终能通过读侧召回回到同一个场景。 +func TestWrittenRefReachableFromItsScene(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + sig := mkSig("qq", "", "") + var key string + for i := 0; i < 3 && key == ""; i++ { + k, _, err := g.EnterScene(sig) + if err != nil { + t.Fatal(err) + } + key = k + } + if key == "" { + t.Fatal("未建出场景") + } + + // 写侧走 effectiveScenes(它会归一化)——这正是生产代码的路径 + ec, rc, err := g.Commit([]Triple{{ + Subject: "老大", Relation: "偏好", Object: "咖啡", + Scenes: []string{key}, // 模型/内核给的键,可能带 + 或 _ + }}, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec == 0 || rc == 0 { + t.Fatal("三元组未写入") + } + + // 读侧也走归一化(RecallByScene 内部会做) + res, err := g.RecallByScene([]string{key}, 8) + if err != nil { + t.Fatal(err) + } + if len(res.Relations) == 0 { + t.Fatalf("刚写进场景 %q 的关系,经同键召回却取不回 —— "+ + "建键与写/读两侧对「同一个键」的认定不一致", key) + } +} + +// sceneKeys 直查 scenes 表(不引用被测统计函数,独立复算)。 +func sceneKeys(t *testing.T, g *GraphDB) []string { + t.Helper() + rows, err := g.db.Query(`SELECT key FROM scenes ORDER BY id`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + t.Fatal(err) + } + out = append(out, k) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +}