fix(core): P0-3 翻页切片豁免 pattern/peer carry(t_58f9d476)

capRecallResults 把 pattern/peer carry 恒保留追加在 prim 之后(回填保证
主+扩展填满 limit ⇒ carry 恒在索引 limit 处),紧随的 P0-3 裸
results[:req.Limit] 把它恒切掉 —— t_e98cb1d6 的 carry 修复被作废,
ZHIYI_PEER_RECALL 结构性零产出(dbg 插桩实证注入发生 4087→4088 但响应无 peer)。

修法:翻页逻辑抽成纯函数 paginateRecallWithCarry —— carry(Category ∈
{pattern,peer},判据与 capRecallResults 同源)先摘出 → 主路段照旧做
offset 切片/prevIDs 去重/[:limit] 截断 → 最后把 carry 回尾。
关态(无 carry)与旧内联逻辑逐位 delta=0;主路段 ≤ limit 语义不变(t_9025b0eb)。

回归 TestPaginateRecallWithCarry(6 例):limit=5/50 末位 peer 存活、
pattern+peer 全存活、关态逐位一致、offset 越界空页、续页 carry 回尾、
端到端 cap→paginate 链路。全部既有测试绿(routes 失败集与 HEAD 逐位一致,
仅 3 个 pattern 预存在失败,非本改动引入)。

影子双臂(修复版二进制,独立数据副本+sidecar/redis/PORT):含 peer_id
query 在 limit=5/50 响应末位现 peer:muc;金标 20 query diff top1 翻转 0、
top5 主路丢失 0、主路顺序破坏 0,7 条 peer 注入全存活且 trigger_hit;
off 臂与生产活体 3/3 top1+主路 top5 逐位一致。
生产零改动(未部署未重启,prod :7821 health=200)。
This commit is contained in:
小唯
2026-11-02 21:07:19 +08:00
parent 75abce7cca
commit 1774c310b1
2 changed files with 232 additions and 23 deletions
+59 -23
View File
@@ -911,30 +911,14 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
// ── P0-3 翻页:从 fetchLimit 结果里切出 [offset, offset+limit) ──
// 说明:翻页重算整段前 offset+limit 条再切片,保证 MMR 前缀稳定;
// 令牌绑定 query 指纹 + namespace,跨 query/跨域复用一律 400(防串页)。
// ⚠ 2026-11-02 (t_58f9d476):切片必须对 pattern/peer carry 豁免——
// capRecallResults 特意把 carry 恒保留追加在 prim 之后(回填保证主+扩展填满
// limit ⇒ carry 恒落在索引 limit),本块裸 results[:limit] 把它恒切掉,
// t_e98cb1d6 的 carry 修复在此被整体作废(ZHIYI_PEER_RECALL 结构性零产出,
// dbg 插桩实证注入发生 4087→4088 但响应无 peer)。语义改走纯函数
// paginateRecallWithCarry(可单测):carry 摘出→offset/去重/截断→回尾。
totalFetched := len(results)
if offset > 0 {
if offset >= len(results) {
results = []models.RecallResult{}
} else {
results = results[offset:]
}
}
if len(prevIDs) > 0 {
seenPrev := make(map[string]bool, len(prevIDs))
for _, id := range prevIDs {
seenPrev[id] = true
}
kept := results[:0]
for _, r := range results {
if !seenPrev[r.ID] {
kept = append(kept, r)
}
}
results = kept
}
if len(results) > req.Limit {
results = results[:req.Limit]
}
results = paginateRecallWithCarry(results, offset, prevIDs, req.Limit)
nextCont := ""
if totalFetched >= fetchLimit && len(results) == req.Limit && offset+req.Limit < maxContinuationOffset {
returned := make([]string, 0, len(prevIDs)+len(results))
@@ -1699,3 +1683,55 @@ func capRecallResults(results []models.RecallResult, limit int) (prim, expanded
}
return prim, expanded
}
// paginateRecallWithCarry —— P0-3 翻页切片,对 pattern/peer carry 豁免
// (2026-11-02 t_58f9d476;carry 判据与 capRecallResults 同源:Category ∈ {pattern, peer})。
//
// 病灶:capRecallResults 把 carry 恒保留追加在 prim 之后(主+扩展回填恒填满 limit
// ⇒ carry 恒在索引 limit 处),紧随的 P0-3 裸 `results[:req.Limit]` 恒把它切掉
// ——t_e98cb1d6 的 carry 修复被作废,peer/pattern 注入在主召回响应里结构性失明。
//
// 语义(纯函数,可单测):先把 carry 摘出,对剩余主路段照旧做
// offset 切片 → prevIDs 去重 → [:limit] 截断,最后把 carry 回尾。
// 保证:① 主路段逐位不变(t_9025b0eb AC 不动:主路段 ≤ limit);
// ② carry 恒存活且恒在响应末尾;③ 无 carry(门关态)逐位与旧内联逻辑 delta=0;
// ④ 主路段切空(offset 越界 / 去重全掉)时返回空段,不返回孤儿 carry。
func paginateRecallWithCarry(results []models.RecallResult, offset int, prevIDs []string, limit int) []models.RecallResult {
var carry []models.RecallResult
main := make([]models.RecallResult, 0, len(results))
for _, r := range results {
if r.Category == "pattern" || r.Category == "peer" {
carry = append(carry, r)
} else {
main = append(main, r)
}
}
if offset > 0 {
if offset >= len(main) {
main = []models.RecallResult{}
} else {
main = main[offset:]
}
}
if len(prevIDs) > 0 {
seenPrev := make(map[string]bool, len(prevIDs))
for _, id := range prevIDs {
seenPrev[id] = true
}
kept := main[:0]
for _, r := range main {
if !seenPrev[r.ID] {
kept = append(kept, r)
}
}
main = kept
}
if limit >= 0 && len(main) > limit {
main = main[:limit]
}
if len(main) == 0 {
// 与旧语义一致:主路段空 ⇒ 整页空(页面上只有注入条目没有意义)
return []models.RecallResult{}
}
return append(main, carry...)
}
+173
View File
@@ -1,6 +1,7 @@
package routes
import (
"fmt"
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
@@ -151,3 +152,175 @@ func TestCapRecallResults_PatternAndPeerSurviveCap(t *testing.T) {
})
}
}
// ── t_58f9d476 回归:P0-3 翻页切片吞掉 carry ──────────────────────────────
// 病灶:capRecallResults 之后 results = [主(填满 limit)..., carry...],紧随的
// P0-3 翻页块裸 `results[:req.Limit]` 恒把 carry 切掉(carry 恰在索引 limit)
// ⇒ t_e98cb1d6 的 carry 修复被作废,peer/pattern 注入结构性失明
// (t_9dc647f1 dbg 插桩实证:注入 4087→4088 发生,响应 50 条无 peer)。
// 修后:paginateRecallWithCarry 对 carry 豁免切片并回尾;关态(无 carry)逐位 delta=0。
func TestPaginateRecallWithCarry(t *testing.T) {
mkMain := func(n int) []models.RecallResult {
out := make([]models.RecallResult, n)
for i := 0; i < n; i++ {
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
}
return out
}
carry := func(rs ...models.RecallResult) []models.RecallResult { return rs }
// ① 金标场景:limit=5、主+扩展填满 limit、末位=peer ⇒ 切片后末位仍 = peer:<id>,
// 且主路段逐位 = 原前 5 位(t_9025b0eb AC:主路 ≤ limit 不变)。
t.Run("limit=5_末位peer存活_主路段逐位不变", func(t *testing.T) {
in := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer", Content: "p"})
got := paginateRecallWithCarry(in, 0, nil, 5)
if len(got) != 6 {
t.Fatalf("len=%d want 6(主 5 + carry 1)", len(got))
}
if got[5].ID != "peer:muc" || got[5].Category != "peer" {
t.Fatalf("末位=%s/%s,want peer:muc/peer(截断回归未修)", got[5].ID, got[5].Category)
}
for i := 0; i < 5; i++ {
if got[i].ID != in[i].ID {
t.Fatalf("主路段第 %d 位变了: got=%s want=%s", i, got[i].ID, in[i].ID)
}
}
})
// ② limit=50、pattern(3)+peer(1) 全存活,主路段逐位不变。
t.Run("limit=50_pattern与peer全存活", func(t *testing.T) {
in := append(mkMain(50), carry(
models.RecallResult{ID: "pattern:a", Category: "pattern"},
models.RecallResult{ID: "pattern:b", Category: "pattern"},
models.RecallResult{ID: "pattern:c", Category: "pattern"},
models.RecallResult{ID: "peer:muc", Category: "peer"},
)...)
got := paginateRecallWithCarry(in, 0, nil, 50)
if len(got) != 54 {
t.Fatalf("len=%d want 54", len(got))
}
for i := 0; i < 50; i++ {
if got[i].ID != in[i].ID {
t.Fatalf("主路段第 %d 位变了", i)
}
}
if got[53].ID != "peer:muc" {
t.Fatalf("末位=%s want peer:muc", got[53].ID)
}
nPat := 0
for _, r := range got {
if r.Category == "pattern" {
nPat++
}
}
if nPat != 3 {
t.Fatalf("pattern=%d want 3", nPat)
}
})
// ③ 关态(无 carry)逐位 delta=0:offset × prevIDs × limit 组合下
// 与旧内联语义(切片→去重→截断)逐一比对。
t.Run("关态无carry_与旧内联逻辑逐位一致", func(t *testing.T) {
legacy := func(in []models.RecallResult, offset int, prevIDs []string, limit int) []models.RecallResult {
results := in
if offset > 0 {
if offset >= len(results) {
results = []models.RecallResult{}
} else {
results = results[offset:]
}
}
if len(prevIDs) > 0 {
seen := make(map[string]bool, len(prevIDs))
for _, id := range prevIDs {
seen[id] = true
}
kept := make([]models.RecallResult, 0, len(results))
for _, r := range results {
if !seen[r.ID] {
kept = append(kept, r)
}
}
results = kept
}
if len(results) > limit {
results = results[:limit]
}
return results
}
in := mkMain(30)
for _, offset := range []int{0, 1, 7, 29, 30, 45} {
for _, prev := range [][]string{nil, {"mem_008"}, {"mem_008", "mem_009", "mem_020"}} {
for _, limit := range []int{5, 10, 50} {
got := paginateRecallWithCarry(in, offset, prev, limit)
want := legacy(in, offset, prev, limit)
if len(got) != len(want) {
t.Fatalf("offset=%d prev=%v limit=%d: len got=%d want=%d", offset, prev, limit, len(got), len(want))
}
for i := range got {
if got[i].ID != want[i].ID {
t.Fatalf("offset=%d prev=%v limit=%d: 第%d位 got=%s want=%s", offset, prev, limit, i, got[i].ID, want[i].ID)
}
}
}
}
}
})
// ④ 主路段切空 ⇒ 整页空(不返回孤儿 carry,与旧语义一致)。
t.Run("offset越界_整页空不带孤儿carry", func(t *testing.T) {
in := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer"})
if got := paginateRecallWithCarry(in, 5, nil, 5); len(got) != 0 {
t.Fatalf("len=%d want 0", len(got))
}
})
// ⑤ 续页(offset>0 + prevIDs 去重)carry 仍回尾,主路段从 offset 起且去掉 prevIDs。
t.Run("续页carry回尾_主路段offset加去重", func(t *testing.T) {
in := append(mkMain(20), models.RecallResult{ID: "peer:muc", Category: "peer"})
got := paginateRecallWithCarry(in, 3, []string{"mem_005"}, 5)
if len(got) != 6 {
t.Fatalf("len=%d want 6", len(got))
}
wantMain := []string{"mem_003", "mem_004", "mem_006", "mem_007", "mem_008"}
for i, id := range wantMain {
if got[i].ID != id {
t.Fatalf("主路段第 %d 位 got=%s want=%s", i, got[i].ID, id)
}
}
if got[5].ID != "peer:muc" {
t.Fatalf("末位=%s want peer:muc", got[5].ID)
}
})
// ⑥ 端到端:capRecallResults → paginateRecallWithCarry 全链路
// (生产形态:主 60 + 扩展 100 + pattern 3 + peer 1,limit=50)。
t.Run("端到端cap后翻页_peer不再被切", func(t *testing.T) {
var all []models.RecallResult
all = append(all, mkMain(60)...)
for i := 0; i < 100; i++ {
all = append(all, models.RecallResult{ID: fmt.Sprintf("gx_%03d", i), Category: "graph_expanded", Content: "[graph] x"})
}
all = append(all,
models.RecallResult{ID: "pattern:a", Category: "pattern"},
models.RecallResult{ID: "peer:muc", Category: "peer"})
prim, _ := capRecallResults(all, 50)
if prim[len(prim)-1].Category != "peer" {
t.Fatalf("cap 后末位=%s,capRecallResults 语义变了", prim[len(prim)-1].ID)
}
got := paginateRecallWithCarry(prim, 0, nil, 50)
// 修前此处恒被切(results[:50] 丢掉末尾 carry)
if got[len(got)-1].ID != "peer:muc" {
t.Fatalf("翻页后末位=%s want peer:muc(P0-3 截断回归未修)", got[len(got)-1].ID)
}
nMain := 0
for _, r := range got {
if r.Category != "pattern" && r.Category != "peer" {
nMain++
}
}
if nMain > 50 {
t.Fatalf("主路段 %d 条 > limit 50(t_9025b0eb AC 破坏)", nMain)
}
})
}