package memory import ( "context" "fmt" "testing" ) // 本文件是「单分片韧性」的断言(M61,2026-10-30;design/23 §15.3 已登记口径): // 一条慢/坏分片**不丢整条**——同一批里其它条目的向量照常返回,只损失那一条; // 而整体性故障(服务挂死)**不放大**:整批在第一个 chunk 即失败 + 逐条 B 次 // ⇒ 1+B 个请求就收手(B = 批上限 8)。 // // ⚠️ 请求数口径是**两维**的(见 embed_isolation.go 文件头):本文件的正文都很短 // (8 条合计远小于 DefaultBatchChars=480)⇒ chunkTexts 只按条数界切、C=1,因此 // 「含一条坏正文 = 1+B」在这些用例里是精确值;长正文批的真实上界是 C+B。 // TestEmbedIsolation_OneBadTextKeepsTheRest 是韧性的主断言:批里有一条坏正文时, // 其余 7 条照常拿到向量,坏的那条记为缺口,请求数被逐条隔离的成本封顶。 // // 删掉 embedIsolated(改回「一次 Embed 定生死」)这一条会打红:整批返回错误、 // 7 条好正文的向量一起丢掉。 func TestEmbedIsolation_OneBadTextKeepsTheRest(t *testing.T) { t.Parallel() ctx := context.Background() srv := newFakeEmbedServer(t) srv.failIf = "毒" emb := srv.embedder() texts := make([]string, 0, tidyEmbedBatch) bad := -1 for i := 0; i < tidyEmbedBatch; i++ { text := fmt.Sprintf("正常正文 第%d条", i) if i == tidyEmbedBatch-1 { text = "毒 这一条会让整批请求失败" bad = i } texts = append(texts, text) } base := srv.count() vecs, err := embedIsolated(ctx, emb, texts) reqs := srv.count() - base if err != nil { t.Fatalf("embedIsolated: %v(一条坏分片不该丢掉整条调用)", err) } if len(vecs) != len(texts) { t.Fatalf("向量条数 = %d, want %d(必须与入参对齐)", len(vecs), len(texts)) } if len(vecs[bad]) != 0 { t.Fatalf("坏正文居然拿到了向量:%v", vecs[bad]) } kept := 0 for i := range vecs { if i == bad { continue } if len(vecs[i]) == 0 { t.Fatalf("第 %d 条好正文被坏分片连坐了(向量为空)", i) } kept++ } if kept != len(texts)-1 { t.Fatalf("保住的条数 = %d, want %d", kept, len(texts)-1) } // 逐条隔离的成本上界:整批 1 个 chunk(本例正文短 ⇒ C=1)+ 逐条重试 B 次 // ⇒ 1+B = 9。长正文批的真实上界是 C+B(C 由 480 字字符界决定,见文件头)。 if limit := 1 + tidyEmbedBatch; reqs > limit { t.Fatalf("定位一条坏分片花了 %d 个请求,超过逐条上界 %d(韧性变成了放大)", reqs, limit) } t.Logf("一条坏分片:保住 %d/%d 条,代价 %d 个请求(短正文上界 1+B = %d)", kept, len(texts), reqs, 1+tidyEmbedBatch) } // TestEmbedIsolation_OutageStopsFast 是**反放大**的断言:服务整体挂死时不许把 // 「用请求换韧性」变成请求风暴 —— 逐条重试一条都没拿到即判定整体性故障,当场 // 返回错误,且请求数被 1+B 封顶(整批在第一个 chunk 就失败 ⇒ 1 个请求 + 逐条 B; // 这一条与字符维的 chunk 数无关,本例正文短、C 本来就是 1)。 func TestEmbedIsolation_OutageStopsFast(t *testing.T) { t.Parallel() ctx := context.Background() srv := newFakeEmbedServer(t) srv.failAll = true emb := srv.embedder() texts := make([]string, 0, tidyEmbedBatch) for i := 0; i < tidyEmbedBatch; i++ { texts = append(texts, fmt.Sprintf("正文 %d", i)) } base := srv.count() vecs, err := embedIsolated(ctx, emb, texts) reqs := srv.count() - base if err == nil { t.Fatalf("服务整体不可用却返回成功:%v", vecs) } if limit := 1 + tidyEmbedBatch; reqs > limit { t.Fatalf("整体性故障发了 %d 个请求,want ≤ %d(整批 1 + 逐条 B)", reqs, limit) } t.Logf("整体性故障:%d 个请求后收手(无放大),错误 = %v", reqs, err) } // TestEmbedIncremental_PartialGapKeepsGoing 是端到端的韧性:24 条里 1 条坏正文, // 其余 23 条照常带向量返回,统计里如实记 1 个缺口(绝不静默)。 func TestEmbedIncremental_PartialGapKeepsGoing(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) items := reviewItems(t, store, 24) srv := newFakeEmbedServer(t) srv.failIf = "毒" emb := srv.embedder() store.attachVectorIndex(fakeIndex(t, emb)) // 把第 9 条换成坏正文(用一条新条目顶掉,保证它也在 missing 里)。 items[8] = addItem(t, store, MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: "毒 这条正文每次请求都失败", }) vecs, stats, err := store.embedIncremental(ctx, emb, items, tidyEmbedBatch) if err != nil { t.Fatalf("embedIncremental: %v(一条坏正文不该丢掉整轮召回)", err) } if stats.Dropped != 1 { t.Fatalf("缺口数 = %d, want 1(缺口必须如实计数)", stats.Dropped) } if stats.Filled != len(items)-1 { t.Fatalf("回填数 = %d, want %d", stats.Filled, len(items)-1) } if len(vecs[8]) != 0 { t.Fatalf("坏正文居然拿到了向量") } for i := range vecs { if i == 8 { continue } if len(vecs[i]) == 0 { t.Fatalf("第 %d 条被坏分片连坐了", i) } } } // TestReviewRecall_SelfGapDegradesToLexical 是「被审的那一条自己没向量」的口径: // 此时语义召回对它无从谈起(相似度恒 0),必须**如实降级**到词项余弦并标记 // degraded,而不是交出一片看起来「没有重复」的空候选。 func TestReviewRecall_SelfGapDegradesToLexical(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) reviewItems(t, store, 3) srv := newFakeEmbedServer(t) srv.failIf = "毒" emb := srv.embedder() store.attachVectorIndex(fakeIndex(t, emb)) reviewer := newTestReviewer(t, store, emb, 0.80) item := addItem(t, store, MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: "毒 被审的这条没有向量", }) _, degraded, err := reviewer.recallCandidates(ctx, item) if err != nil { t.Fatalf("recallCandidates: %v(缺口该走降级,不该整条失败)", err) } if !degraded { t.Fatal("被审条目没有向量却把自己报成未降级:那会把「算不出来」说成「没有重复」") } }