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:
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user