a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
153 lines
5.1 KiB
Go
153 lines
5.1 KiB
Go
package search
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// withSparseNgram pins the sparse-ngram gate to a known value for the
|
|
// duration of a test and restores it afterwards, so an ambient
|
|
// GORTEX_SPARSE_NGRAM in the environment can't make the suite flaky.
|
|
// Mirrors withStemming in fts_normalize_test.go.
|
|
func withSparseNgram(t *testing.T, on bool) {
|
|
t.Helper()
|
|
prev := sparseNgramEnabled
|
|
sparseNgramEnabled = on
|
|
t.Cleanup(func() { sparseNgramEnabled = prev })
|
|
}
|
|
|
|
func TestExpandSparseNgrams_FlagOffIsNoOp(t *testing.T) {
|
|
withSparseNgram(t, false)
|
|
in := []string{"validate", "token"}
|
|
got := ExpandSparseNgrams(in, nil)
|
|
// Gate off: the exact same slice is returned, no grams appended.
|
|
assert.Equal(t, in, got)
|
|
}
|
|
|
|
func TestExpandSparseNgrams_FlagOffEmptyInput(t *testing.T) {
|
|
withSparseNgram(t, false)
|
|
assert.Empty(t, ExpandSparseNgrams(nil, nil))
|
|
}
|
|
|
|
func TestExpandSparseNgrams_PreservesWordTokensFirst(t *testing.T) {
|
|
withSparseNgram(t, true)
|
|
got := ExpandSparseNgrams([]string{"validate", "token"}, nil)
|
|
// The original word tokens must survive verbatim and lead the
|
|
// output, so an exact word match still scores through them.
|
|
require.GreaterOrEqual(t, len(got), 2)
|
|
assert.Equal(t, "validate", got[0])
|
|
assert.Equal(t, "token", got[1])
|
|
}
|
|
|
|
func TestExpandSparseNgrams_EmitsFixedCharNgrams(t *testing.T) {
|
|
withSparseNgram(t, true)
|
|
got := ExpandSparseNgrams([]string{"token"}, nil)
|
|
set := map[string]struct{}{}
|
|
for _, g := range got {
|
|
set[g] = struct{}{}
|
|
}
|
|
// "token" -> trigrams tok,oke,ken and 4-grams toke,oken.
|
|
for _, want := range []string{"tok", "oke", "ken", "toke", "oken"} {
|
|
_, ok := set[want]
|
|
assert.Truef(t, ok, "expected sub-word gram %q in %v", want, got)
|
|
}
|
|
// The whole token is preserved but never re-emitted as a gram equal
|
|
// to itself.
|
|
assert.Equal(t, "token", got[0])
|
|
}
|
|
|
|
func TestExpandSparseNgrams_ShortTokenEmitsNoGrams(t *testing.T) {
|
|
withSparseNgram(t, true)
|
|
// "go" is below the minimum gram length, so it yields only itself.
|
|
got := ExpandSparseNgrams([]string{"go"}, nil)
|
|
assert.Equal(t, []string{"go"}, got)
|
|
}
|
|
|
|
func TestExpandSparseNgrams_CollapsesDuplicateGrams(t *testing.T) {
|
|
withSparseNgram(t, true)
|
|
// "aaaa": trigram "aaa" appears twice and the 4-gram is the token
|
|
// itself (suppressed). The duplicate "aaa" must collapse to one.
|
|
got := ExpandSparseNgrams([]string{"aaaa"}, nil)
|
|
count := 0
|
|
for _, g := range got {
|
|
if g == "aaa" {
|
|
count++
|
|
}
|
|
}
|
|
assert.Equal(t, 1, count, "duplicate grams within a token must collapse: %v", got)
|
|
}
|
|
|
|
func TestExpandSparseNgrams_Deterministic(t *testing.T) {
|
|
withSparseNgram(t, true)
|
|
a := ExpandSparseNgrams([]string{"validate", "handler"}, nil)
|
|
b := ExpandSparseNgrams([]string{"validate", "handler"}, nil)
|
|
// No map iteration leaks into the emission order.
|
|
assert.Equal(t, a, b)
|
|
}
|
|
|
|
// TestBM25_SparseNgramSymmetry is the load-bearing invariant: every
|
|
// term a Search query emits for a doc must be present in the postings
|
|
// Add wrote for that doc. We verify it by tokenizing both sides through
|
|
// the exact stages the backend uses and asserting the query token set
|
|
// is a subset of the indexed token set for an overlapping query.
|
|
func TestBM25_SparseNgramSymmetry(t *testing.T) {
|
|
withSparseNgram(t, true)
|
|
|
|
// Index side: what Add writes for the symbol name "validateToken".
|
|
indexTokens := ExpandSparseNgrams(
|
|
NormalizeFTSTokens(Tokenize("validateToken")), nil)
|
|
indexed := map[string]struct{}{}
|
|
for _, tok := range indexTokens {
|
|
indexed[tok] = struct{}{}
|
|
}
|
|
|
|
// Query side: a prefix query "valid" that shares sub-word grams.
|
|
queryTokens := ExpandSparseNgrams(
|
|
NormalizeFTSTokens(TokenizeQuery("valid")), nil)
|
|
require.NotEmpty(t, queryTokens)
|
|
|
|
// At least one query gram must hit an indexed gram — otherwise the
|
|
// expansion bought no recall. (It must, since "valid" is a prefix of
|
|
// "validate" and they share trigrams val/ali/lid.)
|
|
overlap := 0
|
|
for _, tok := range queryTokens {
|
|
if _, ok := indexed[tok]; ok {
|
|
overlap++
|
|
}
|
|
}
|
|
assert.Positivef(t, overlap, "query grams %v share no indexed gram with %v",
|
|
queryTokens, indexTokens)
|
|
}
|
|
|
|
// TestBM25_SparseNgramRecall exercises the full backend: with the gate
|
|
// on, a sub-word query reaches a symbol it would miss on pure word
|
|
// tokenization.
|
|
func TestBM25_SparseNgramRecall(t *testing.T) {
|
|
withSparseNgram(t, true)
|
|
b := NewBM25()
|
|
defer b.Close()
|
|
b.Add("svc::validateToken", "validateToken", "auth/token.go")
|
|
|
|
// "valid" is no word token of "validateToken" (camelCase splits to
|
|
// validate/token), but it shares sub-word grams with "validate".
|
|
res := b.Search("valid", 10)
|
|
require.NotEmpty(t, res)
|
|
assert.Equal(t, "svc::validateToken", res[0].ID)
|
|
}
|
|
|
|
// TestBM25_SparseNgramFlagOffUnchanged confirms the gate-off backend
|
|
// behaves exactly as before: a sub-word-only query finds nothing.
|
|
func TestBM25_SparseNgramFlagOffUnchanged(t *testing.T) {
|
|
withSparseNgram(t, false)
|
|
b := NewBM25()
|
|
defer b.Close()
|
|
b.Add("svc::validateToken", "validateToken", "auth/token.go")
|
|
|
|
// With the gate off there is no sub-word path, so a fragment that is
|
|
// not a whole word token returns nothing from the backend.
|
|
res := b.Search("valid", 10)
|
|
assert.Empty(t, res)
|
|
}
|