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
305 lines
9.9 KiB
Go
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")
|
|
}
|
|
}
|