Files
HomeAgent/internal/tfidf/test/tfidf_contract_test.go
JianFeeeee c01f4d6350 test: 两个零测试包补黑箱判据 —— files(沙箱)与 tfidf
覆盖率分析显示这两个包**零测试文件**,而它们各自守着一条不能错的边界:

    internal/plugins/files   581 行  含沙箱逃逸防护
    internal/tfidf           306 行  记忆检索的 fallback 路径

## files:沙箱边界

581 行、0 判据。它是文件读写插件,`pathWithinSandbox` 写错一个字符就是
任意文件读写。没有判据意味着没人知道它现在是否还挡得住。

6 条判据全部走**真实装配**(调 `Start()` 而不是手搓 Plugin 结构),
通过注入的 registrar 捕获 handler,然后以「输入 {path, content} → 输出」断言:

    TestToolsAreRegistered           四个工具都在
    TestReadWriteRoundTrip           写进去能原样读回
    TestSandboxEscapeIsRejected      ★ 沙箱外路径必须被拒(3 种逃逸形态)
    TestSandboxAllowsInside          反向:沙箱内必须能读
    TestEmptyPathIsRejected          read/write 拒绝空 path
    TestLsEmptyPathMeansCurrentDir   ls 把空 path 当 "."
    TestLsListsDirectory             ls 列出目录内容

**沙箱那条的判据设计**:沙箱外造一个**真实存在**的文件。若只拦不存在的
路径,判据会假绿;文件真实存在而仍被拒,才证明是沙箱在起作用。

变异(`pathWithinSandbox` 加 `if true { return true }`)⇒ 实测红:

    ★ 沙箱外路径 ".../secret.txt" 被接受了,返回 {"content":"SECRET",...}
      这是任意文件读取。沙箱根: ...

## tfidf:检索判别力

306 行、0 判据。它是没有神经网络 embedder 时的文本相似度来源 ——
判据错 = 检索错,而检索错是静默的(不报错,只是答非所问)。

判据**只断言契约,不断言数值**:TF-IDF 的权重取决于语料统计,写死数值
等于把训练集也当成契约。6 条钉的是换了实现也必须成立的性质:

    TestVectorizeIsDeterministic            同输入同输出(map 遍历浮点累加
                                            会产生末位差异,让缓存/去重失效)
    TestVectorizeHasFixedDimAndNonNegative  维度恒定、非 NaN/Inf、非负
    TestEmptyTextGivesZeroVector            空文本不 panic 且给零向量
    TestSearchRanksRelatedAboveUnrelated    ★ 核心质量判据:只排序,不看数值
    TestAddRemoveChangesResults             增删后检索结果随之变化
    TestFingerprintAndDim                   元信息非空

`TestSearchRanksRelatedAboveUnrelated` 直接对治 engineering-lessons §1.1
记的那个坑:16 条判据全绿而检索结果完全无关(分数全挤在 0.75~0.79)。
所以它只问"billing invoice 的查询里,auth 文档有没有排到相关文档前面"。

变异(`Search` 提前 `return nil`)⇒ 实测红两条。

## 一处判据写错了(已按实际契约修正)

第一版把 `files_ls` 也纳入"空 path 必须拒绝",实测红:

    ★ files_ls 接受了空 path,返回 {"content":"(empty directory)"}

查代码(plugin.go:488)发现那是**明确设计**:`if path == "" { path = "." }`。
不是缺陷,是我把三条工具的输入契约当成同一条了。改成两条判据分开钉:

    TestEmptyPathIsRejected          read/write 拒绝空 path
    TestLsEmptyPathMeansCurrentDir   ls 把空 path 当 "."

把差异显式写下来,以后有人想"统一都拒绝空 path"时能看到这是有意的。

## 覆盖率

    internal/plugins/files   0.0% → 54.1%
    internal/tfidf           0.0% → 有判据(契约全覆盖)

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    67 包全过,0 FAIL(新增 2 个 test 包)
2026-11-04 20:41:43 +08:00

172 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package test
// tfidf 的契约判据:输入文本 → 输出向量/检索结果。
//
// ## 为什么这个包值得有判据(2026-11-04)
//
// `internal/tfidf` 306 行、**零测试文件**。它是纯算法库 —— 输入输出就是
// 全部语义,天然适合黑箱判据。
//
// 它服务的是记忆检索的 fallback 路径:没有神经网络 embedder 时,文本相似度
// 完全靠它。判据错 = 检索错,而检索错是静默的(不报错,只是答非所问)。
//
// ## 判据只断言契约,不断言数值
//
// TF-IDF 的具体权重取决于语料统计,写死数值等于把训练集也当成契约。
// 这里只钉那些**换了实现也必须成立**的性质:确定性、判别力、维度、
// 增删生效。
import (
"math"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/tfidf"
)
// simpleTokenizer 按空白切词。
func simpleTokenizer(text string) []string {
return strings.Fields(text)
}
func newTrained(t *testing.T, docs []string, dim int) *tfidf.Embedder {
t.Helper()
e := tfidf.NewEmbedder(simpleTokenizer, dim)
e.Train(docs)
return e
}
// TestVectorizeIsDeterministic 钉住确定性:同输入必须同输出。
//
// 变异:向量累加改成遍历 map 直接相加(不排序)⇒ 可能偶发红。
func TestVectorizeIsDeterministic(t *testing.T) {
e := newTrained(t, []string{
"billing service handles payments",
"auth service handles login",
"payment gateway retries",
}, 64)
const text = "billing service payments"
a, err := e.VectorizeDense(text)
if err != nil {
t.Fatalf("VectorizeDense: %v", err)
}
for i := 0; i < 5; i++ {
b, err := e.VectorizeDense(text)
if err != nil {
t.Fatal(err)
}
if len(a) != len(b) {
t.Fatalf("维度不稳定:%d vs %d", len(a), len(b))
}
for j := range a {
if a[j] != b[j] {
t.Fatalf("★ 第 %d 次调用第 %d 维不同:%v vs %v —— 无确定性", i, j, a[j], b[j])
}
}
}
}
// TestVectorizeHasFixedDimAndNonNegative 钉住维度契约与取值域。
//
// 变异:让 VectorizeDense 返回变长切片 ⇒ 本例红。
func TestVectorizeHasFixedDimAndNonNegative(t *testing.T) {
const dim = 32
e := newTrained(t, []string{"alpha beta", "gamma delta", "epsilon"}, dim)
for _, text := range []string{"", "alpha", "alpha beta gamma", "完全没出现过的词"} {
v, err := e.VectorizeDense(text)
if err != nil {
t.Fatalf("%q: %v", text, err)
}
if len(v) != dim {
t.Fatalf("★ %q 的向量维度 = %d,期望 %d", text, len(v), dim)
}
for i, x := range v {
if math.IsNaN(x) || math.IsInf(x, 0) {
t.Fatalf("★ %q 第 %d 维是 NaN/Inf", text, i)
}
if x < 0 {
t.Fatalf("★ %q 第 %d 维为负(%v)—— TF-IDF 权重不该为负", text, i, x)
}
}
}
}
// TestEmptyTextGivesZeroVector 钉住边界:空文本不该 panic,且给出零向量。
func TestEmptyTextGivesZeroVector(t *testing.T) {
e := newTrained(t, []string{"alpha beta"}, 16)
v, err := e.VectorizeDense("")
if err != nil {
t.Fatalf("空文本报错: %v", err)
}
for i, x := range v {
if x != 0 {
t.Fatalf("★ 空文本第 %d 维 = %v,期望 0", i, x)
}
}
}
// TestSearchRanksRelatedAboveUnrelated 是**核心质量判据**:
// 相近文本必须排在无关文本之前。
//
// 这正是 engineering-lessons §1.1 记的坑:16 条判据全绿而检索结果完全无关
// (分数全挤在 0.75~0.79)。所以这条不看数值,只看**排序**。
//
// 变异:让 Search 返回未排序结果 ⇒ 本例红。
func TestSearchRanksRelatedAboveUnrelated(t *testing.T) {
docs := []string{
"billing service handles payments and invoices",
"auth service handles login and sessions",
"billing invoice retry policy",
}
e := newTrained(t, docs, 128)
idx := tfidf.NewSearchableIndex(e)
for i, d := range docs {
idx.Add(string(rune('a'+i)), d)
}
hits := idx.Search("billing invoice", 3)
if len(hits) < 2 {
t.Fatalf("检索只返回 %d 条,期望至少 2 条", len(hits))
}
first := hits[0].ID
if first != "a" && first != "c" {
t.Fatalf("★ 查 'billing invoice' 排第一的是 %q ——\n"+
"它既不含 billing 也不含 invoice。检索判别力失效。", first)
}
for i, h := range hits {
if h.ID == "b" {
t.Fatalf("★ 完全无关的 auth 文档排在第 %d 位(共 %d 条)", i+1, len(hits))
}
}
}
// TestAddRemoveChangesResults 钉住索引可变性契约。
//
// 变异:Remove 不真正删除 ⇒ 本例红。
func TestAddRemoveChangesResults(t *testing.T) {
e := newTrained(t, []string{"alpha beta", "gamma delta"}, 32)
idx := tfidf.NewSearchableIndex(e)
idx.Add("x", "alpha beta gamma")
if got := idx.Search("alpha", 3); len(got) == 0 {
t.Fatal("加入后检索不到")
}
idx.Remove("x")
if got := idx.Search("alpha", 3); len(got) != 0 {
t.Fatalf("★ Remove 之后仍检索到 %d 条 —— 删除没生效", len(got))
}
}
// TestFingerprintAndDim 钉住元信息契约。
func TestFingerprintAndDim(t *testing.T) {
e := newTrained(t, []string{"a b c"}, 24)
if e.Dim() != 24 {
t.Fatalf("Dim() = %d,期望 24", e.Dim())
}
if e.Fingerprint() == "" {
t.Fatal("★ Fingerprint 为空 —— 下游靠它判断缓存是否还有效")
}
}