Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

305 lines
9.9 KiB
Go

package embedding
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"unicode/utf8"
)
// tinyWordPieceTokenizer is a minimal handcrafted BERT-style WordPiece tokenizer.json
// (lowercasing normalizer, whitespace pre-tokenizer, ~14-entry vocab). Each
// whitespace-separated in-vocab word encodes to exactly one token, which makes the
// truncation cut points deterministic without a real model download.
const tinyWordPieceTokenizer = `{
"version": "1.0",
"truncation": null,
"padding": null,
"added_tokens": [
{"id": 0, "content": "[PAD]", "single_word": false, "lstrip": false, "rstrip": false, "normalized": false, "special": true},
{"id": 100, "content": "[UNK]", "single_word": false, "lstrip": false, "rstrip": false, "normalized": false, "special": true},
{"id": 101, "content": "[CLS]", "single_word": false, "lstrip": false, "rstrip": false, "normalized": false, "special": true},
{"id": 102, "content": "[SEP]", "single_word": false, "lstrip": false, "rstrip": false, "normalized": false, "special": true}
],
"normalizer": {"type": "BertNormalizer", "lowercase": true},
"pre_tokenizer": {"type": "BertPreTokenizer"},
"post_processor": null,
"decoder": {"type": "WordPiece", "prefix": "##"},
"model": {
"type": "WordPiece",
"unk_token": "[UNK]",
"continuing_subword_prefix": "##",
"max_input_chars_per_word": 100,
"vocab": {
"[PAD]": 0,
"hello": 1,
"world": 2,
"test": 3,
"[UNK]": 100,
"[CLS]": 101,
"[SEP]": 102,
"the": 104,
"a": 105,
"is": 106,
"this": 107
}
}
}`
// writeModelDir creates a temp model directory containing tokenizer.json and,
// optionally, a config.json declaring max_position_embeddings.
func writeModelDir(t *testing.T, maxPos int) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "tokenizer.json"), []byte(tinyWordPieceTokenizer), 0o644); err != nil {
t.Fatal(err)
}
if maxPos > 0 {
cfg := fmt.Sprintf(`{"max_position_embeddings": %d}`, maxPos)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}
func TestNewTokenTruncator_BudgetFromConfig(t *testing.T) {
// max_position_embeddings 7 minus the two reserved special-token slots = budget 5.
dir := writeModelDir(t, 7)
tr, err := newTokenTruncator(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tr.tk == nil {
t.Fatal("expected a real tokenizer to load")
}
if tr.budget != 5 {
t.Fatalf("budget = %d, want 5", tr.budget)
}
}
func TestNewTokenTruncator_MissingConfigFallback(t *testing.T) {
dir := writeModelDir(t, 0) // no config.json
tr, err := newTokenTruncator(dir)
if err == nil {
t.Fatal("expected an informational error about the fallback budget")
}
if tr.tk == nil {
t.Fatal("tokenizer must still load even when config.json is missing")
}
if tr.budget != fallbackTokenBudget {
t.Fatalf("budget = %d, want fallback %d", tr.budget, fallbackTokenBudget)
}
}
func TestTokenTruncator_ShortTextUntouched(t *testing.T) {
dir := writeModelDir(t, 7) // budget 5
tr, err := newTokenTruncator(dir)
if err != nil {
t.Fatal(err)
}
// 5 runes ≤ budget 5 → fast path returns the text verbatim.
if got := tr.Truncate("hello"); got != "hello" {
t.Fatalf("Truncate(hello) = %q, want unchanged", got)
}
// 11 runes > budget but only 2 tokens ≤ budget → token-count check returns verbatim.
if got := tr.Truncate("hello world"); got != "hello world" {
t.Fatalf("Truncate(hello world) = %q, want unchanged", got)
}
}
func TestTokenTruncator_OverBudgetCutAtSpanBoundary(t *testing.T) {
dir := writeModelDir(t, 7) // budget 5
tr, err := newTokenTruncator(dir)
if err != nil {
t.Fatal(err)
}
const input = "hello world test the a is this" // 7 in-vocab words → 7 tokens
if n := len(tr.tk.EncodeWithAnnotations(input).IDs); n <= tr.budget {
t.Fatalf("test precondition: input encodes to %d tokens, need > budget %d", n, tr.budget)
}
got := tr.Truncate(input)
if got != "hello world test the a" {
t.Fatalf("Truncate cut = %q, want %q", got, "hello world test the a")
}
if !utf8.ValidString(got) {
t.Fatalf("truncated text is not valid UTF-8: %q", got)
}
if n := len(tr.tk.EncodeWithAnnotations(got).IDs); n > tr.budget {
t.Fatalf("truncated text still encodes to %d tokens, over budget %d", n, tr.budget)
}
}
// TestTokenTruncator_TruncateAll covers the batch API actually wired into every
// provider's EmbedBatch: over-budget entries are shortened, in-budget entries are
// returned untouched, and an all-fits batch returns the input slice unmodified.
func TestTokenTruncator_TruncateAll(t *testing.T) {
dir := writeModelDir(t, 7) // budget 5
tr, err := newTokenTruncator(dir)
if err != nil {
t.Fatal(err)
}
const over = "hello world test the a is this" // 7 tokens > budget 5
out := tr.TruncateAll([]string{over, "hello", "hello world"})
if len(out) != 3 {
t.Fatalf("got %d results, want 3", len(out))
}
if len(tr.tk.EncodeWithAnnotations(out[0]).IDs) > tr.budget {
t.Fatalf("over-budget entry not truncated: %q", out[0])
}
if out[1] != "hello" || out[2] != "hello world" {
t.Fatalf("in-budget entries changed: %q, %q", out[1], out[2])
}
// Nothing over budget -> the exact same slice is returned (no allocation).
fits := []string{"hello", "world"}
if got := tr.TruncateAll(fits); &got[0] != &fits[0] {
t.Fatal("TruncateAll should return the input slice when nothing needs truncating")
}
}
func TestReadTokenBudget(t *testing.T) {
cases := []struct {
name string
configBody string // "" means no config.json
want int
wantErr bool
}{
{"bert-512", `{"max_position_embeddings": 512}`, 510, false},
{"jina-8192", `{"max_position_embeddings": 8192}`, 8190, false},
{"missing", "", fallbackTokenBudget, true},
{"unparseable", `{not json`, fallbackTokenBudget, true},
{"absent-field", `{"hidden_size": 384}`, fallbackTokenBudget, true},
{"implausible", `{"max_position_embeddings": 1}`, fallbackTokenBudget, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if tc.configBody != "" {
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(tc.configBody), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := readTokenBudget(dir)
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if got != tc.want {
t.Fatalf("budget = %d, want %d", got, tc.want)
}
})
}
}
func TestClampRunes(t *testing.T) {
cases := []struct {
name string
text string
maxRunes int
want string
}{
{"multibyte not split", "héllo wörld", 3, "hél"},
{"under cap untouched", "abc", 10, "abc"},
{"zero cap", "abc", 0, ""},
{"exact cap", "abcd", 4, "abcd"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := clampRunes(tc.text, tc.maxRunes)
if got != tc.want {
t.Fatalf("clampRunes(%q, %d) = %q, want %q", tc.text, tc.maxRunes, got, tc.want)
}
if !utf8.ValidString(got) {
t.Fatalf("result not valid UTF-8: %q", got)
}
})
}
}
// TestHugotProvider_LiveTruncatesOverBudgetInput is the end-to-end regression
// for the shape-mismatch crash: an input well past MiniLM's 512-token window
// used to abort the pipeline. With truncation it must embed to a real 384-dim
// vector. Gated on a real cached model — set GORTEX_TEST_LIVE_MODELS=1.
func TestHugotProvider_LiveTruncatesOverBudgetInput(t *testing.T) {
if os.Getenv("GORTEX_TEST_LIVE_MODELS") != "1" {
t.Skip("set GORTEX_TEST_LIVE_MODELS=1 to run against the real MiniLM model")
}
prov, err := newHugotProvider()
if err != nil {
t.Skipf("MiniLM model unavailable: %v", err)
}
defer prov.Close()
// ~120 repetitions of a 9-token sentence ≈ 1000+ tokens — comfortably past
// the 512-token window that used to crash the GO tokenizer path.
over := strings.Repeat("the quick brown fox jumps over the lazy dog ", 120)
vec, err := prov.Embed(context.Background(), over)
if err != nil {
t.Fatalf("embedding an over-budget input failed (truncation regression): %v", err)
}
if len(vec) != prov.Dimensions() {
t.Fatalf("got a %d-dim vector, want %d", len(vec), prov.Dimensions())
}
nonZero := false
for _, v := range vec {
if v != 0 {
nonZero = true
break
}
}
if !nonZero {
t.Fatal("embedding vector is all zeros")
}
// A batch mixing an over-budget input with a short one must also succeed.
vecs, err := prov.EmbedBatch(context.Background(), []string{over, "short input"})
if err != nil {
t.Fatalf("mixed batch failed: %v", err)
}
if len(vecs) != 2 || len(vecs[0]) != prov.Dimensions() || len(vecs[1]) != prov.Dimensions() {
t.Fatalf("mixed batch produced wrong shapes: %d vectors", len(vecs))
}
}
func TestNewTokenTruncator_CorruptTokenizerRuneClamp(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "tokenizer.json"), []byte("{ not a tokenizer"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"max_position_embeddings": 12}`), 0o644); err != nil {
t.Fatal(err)
}
tr, err := newTokenTruncator(dir)
if err == nil {
t.Fatal("expected an error when tokenizer.json is corrupt")
}
if tr == nil {
t.Fatal("truncator must be non-nil even on tokenizer failure")
}
if tr.tk != nil {
t.Fatal("tk must be nil in rune-clamp mode")
}
// clamp must equal the budget (not a multiple): a token spans >= 1 rune, so
// budget runes bound the token count to budget — a looser cap would not.
if tr.budget != 10 || tr.clamp != 10 {
t.Fatalf("budget=%d clamp=%d, want 10/10", tr.budget, tr.clamp)
}
// A long multibyte text is clamped (not split mid-rune) rather than passed through.
long := ""
for i := 0; i < 100; i++ {
long += "café "
}
got := tr.Truncate(long)
if utf8.RuneCountInString(got) > tr.clamp {
t.Fatalf("clamped rune count %d exceeds clamp %d", utf8.RuneCountInString(got), tr.clamp)
}
if !utf8.ValidString(got) {
t.Fatalf("clamped text not valid UTF-8")
}
}