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

322 lines
9.5 KiB
Go
Raw Permalink 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 embedding
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/zzet/gortex/internal/platform"
)
// PotionProvider embeds text with a Model2Vec static embedding model —
// a WordPiece tokenizer plus a single [vocab × dims] embedding matrix.
// Inference is: tokenize → gather the token rows → mean-pool →
// L2-normalise (the model config's `normalize: true` convention, matching
// the reference implementations). No transformer, no positional limit —
// a 50-candidate rerank batch embeds in well under a millisecond.
//
// The bundled model is minishlab/potion-code-16M-v2 (MIT): 256-dim
// vectors over a ~63.5k-token code-mined vocabulary, distilled from a
// code-retrieval teacher. It replaces the older averaged word-vector
// channel for the rerank's semantic-cosine signal.
type PotionProvider struct {
tok *wordPieceTokenizer
mat []float32 // row-major [vocab][dims]
dims int
}
// Potion model pin. The revision and checksums identify the exact
// artifacts the loader accepts; a mismatched download is discarded.
const (
potionModelName = "potion-code-16M-v2"
potionRevision = "d3daf3e31f36d78f75913030b8bdf4a505d5b833"
potionBaseURL = "https://huggingface.co/minishlab/" + potionModelName + "/resolve/" + potionRevision
potionWeightsFile = "model.safetensors"
potionTokenizerFile = "tokenizer.json"
potionWeightsSHA256 = "75cf7a6c2171b230ad19b1e7d8e0b1aee86da5a02af8e7cacedd9921d227623c"
potionTokenizerSHA256 = "107bbdcbad4bff1d299b7a4c3a2fb17c52890688b7dd0e4c9deab79d3c4f3d45"
)
// maxPotionTokens caps how many tokens of one text feed the mean-pool.
// The model is static (no sequence limit), but the rerank only ever
// embeds short name+signature+doc fragments; the cap bounds the cost of
// a pathological input.
const maxPotionTokens = 512
// NewPotionProviderFromDir loads the model from a directory holding
// model.safetensors and tokenizer.json.
func NewPotionProviderFromDir(dir string) (*PotionProvider, error) {
tok, err := loadWordPieceTokenizer(filepath.Join(dir, potionTokenizerFile))
if err != nil {
return nil, err
}
mat, dims, err := loadSafetensorsF16(filepath.Join(dir, potionWeightsFile))
if err != nil {
return nil, err
}
return &PotionProvider{tok: tok, mat: mat, dims: dims}, nil
}
func (p *PotionProvider) Dimensions() int { return p.dims }
func (p *PotionProvider) Close() error { return nil }
func (p *PotionProvider) Embed(_ context.Context, text string) ([]float32, error) {
return p.embed(text), nil
}
func (p *PotionProvider) EmbedBatch(_ context.Context, texts []string) ([][]float32, error) {
out := make([][]float32, len(texts))
for i, t := range texts {
out[i] = p.embed(t)
}
return out, nil
}
func (p *PotionProvider) embed(text string) []float32 {
ids := p.tok.Encode(text)
if len(ids) > maxPotionTokens {
ids = ids[:maxPotionTokens]
}
vec := make([]float32, p.dims)
if len(ids) == 0 {
return vec
}
rows := len(p.mat) / p.dims
n := 0
for _, id := range ids {
if id < 0 || id >= rows {
continue
}
row := p.mat[id*p.dims : (id+1)*p.dims]
for i, v := range row {
vec[i] += v
}
n++
}
if n == 0 {
return vec
}
inv := 1.0 / float32(n)
var norm float64
for i := range vec {
vec[i] *= inv
norm += float64(vec[i]) * float64(vec[i])
}
// L2-normalise (model config `normalize: true`).
if norm > 0 {
s := float32(1.0 / math.Sqrt(norm))
for i := range vec {
vec[i] *= s
}
}
return vec
}
// loadSafetensorsF16 reads a single-tensor safetensors file holding an
// F16 "embeddings" matrix and returns it as row-major float32.
func loadSafetensorsF16(path string) ([]float32, int, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, 0, fmt.Errorf("read weights: %w", err)
}
if len(raw) < 8 {
return nil, 0, fmt.Errorf("weights file too short")
}
hlen := binary.LittleEndian.Uint64(raw[:8])
if hlen == 0 || 8+hlen > uint64(len(raw)) {
return nil, 0, fmt.Errorf("weights header out of range")
}
var header map[string]json.RawMessage
if err := json.Unmarshal(raw[8:8+hlen], &header); err != nil {
return nil, 0, fmt.Errorf("parse weights header: %w", err)
}
var info struct {
Dtype string `json:"dtype"`
Shape []int `json:"shape"`
DataOffsets []int `json:"data_offsets"`
}
entry, ok := header["embeddings"]
if !ok {
return nil, 0, fmt.Errorf("weights file has no 'embeddings' tensor")
}
if err := json.Unmarshal(entry, &info); err != nil {
return nil, 0, fmt.Errorf("parse tensor info: %w", err)
}
if info.Dtype != "F16" {
return nil, 0, fmt.Errorf("unsupported tensor dtype %q (want F16)", info.Dtype)
}
if len(info.Shape) != 2 || len(info.DataOffsets) != 2 {
return nil, 0, fmt.Errorf("unexpected tensor shape/offsets")
}
rows, dims := info.Shape[0], info.Shape[1]
start := 8 + int(hlen) + info.DataOffsets[0]
end := 8 + int(hlen) + info.DataOffsets[1]
if start < 0 || end > len(raw) || end-start != rows*dims*2 {
return nil, 0, fmt.Errorf("tensor data out of range")
}
data := raw[start:end]
mat := make([]float32, rows*dims)
for i := range mat {
mat[i] = f16ToF32(binary.LittleEndian.Uint16(data[i*2 : i*2+2]))
}
return mat, dims, nil
}
// f16ToF32 converts an IEEE-754 half-precision value to float32.
func f16ToF32(h uint16) float32 {
sign := uint32(h>>15) << 31
exp := uint32(h>>10) & 0x1f
man := uint32(h) & 0x3ff
switch exp {
case 0:
if man == 0 {
return math.Float32frombits(sign)
}
// Subnormal half: renormalise into a normal float32.
e := uint32(113) // 127 - 14
for man&0x400 == 0 {
man <<= 1
e--
}
man &= 0x3ff
return math.Float32frombits(sign | e<<23 | man<<13)
case 0x1f:
return math.Float32frombits(sign | 0xff<<23 | man<<13)
default:
return math.Float32frombits(sign | (exp+112)<<23 | man<<13)
}
}
// resolvePotionDir returns the first directory that holds both model
// files, probing in order:
//
// 1. $GORTEX_POTION_DIR — explicit override.
// 2. <executable dir>/models/<model> — the release/bench sidecar. A
// packaged install ships the model next to the binary, so it is
// fully offline after install.
// 3. <gortex home>/models/<model> — the per-user cache the first-use
// download fills.
//
// Returns "" when none has the files.
func resolvePotionDir() string {
var candidates []string
if d := os.Getenv("GORTEX_POTION_DIR"); d != "" {
candidates = append(candidates, d)
}
if exe, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "models", potionModelName))
}
candidates = append(candidates, filepath.Join(platform.ModelsDir(), potionModelName))
for _, dir := range candidates {
if hasPotionFiles(dir) {
return dir
}
}
return ""
}
func hasPotionFiles(dir string) bool {
for _, f := range []string{potionWeightsFile, potionTokenizerFile} {
if st, err := os.Stat(filepath.Join(dir, f)); err != nil || st.IsDir() {
return false
}
}
return true
}
// downloadPotion fetches the pinned model revision into the per-user
// models dir, verifying each file's SHA-256 before moving it into
// place. Returns the directory on success. Disabled entirely when
// GORTEX_POTION_DOWNLOAD=0 (offline installs ship the sidecar instead).
func downloadPotion() (string, error) {
if v := strings.TrimSpace(os.Getenv("GORTEX_POTION_DOWNLOAD")); v == "0" || strings.EqualFold(v, "false") || strings.EqualFold(v, "off") {
return "", fmt.Errorf("model download disabled by GORTEX_POTION_DOWNLOAD")
}
// Test binaries never reach the network: a `go test` run that
// exercises a search path must stay hermetic and fall back to the
// baked vectors instead of pulling 32MB mid-suite.
if strings.HasSuffix(os.Args[0], ".test") {
return "", fmt.Errorf("model download disabled in test binaries")
}
dir := filepath.Join(platform.ModelsDir(), potionModelName)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
client := &http.Client{Timeout: 120 * time.Second}
files := []struct{ name, sum string }{
{potionWeightsFile, potionWeightsSHA256},
{potionTokenizerFile, potionTokenizerSHA256},
}
for _, f := range files {
dst := filepath.Join(dir, f.name)
if st, err := os.Stat(dst); err == nil && !st.IsDir() {
if ok, _ := fileSHA256Matches(dst, f.sum); ok {
continue
}
}
if err := downloadVerified(client, potionBaseURL+"/"+f.name, dst, f.sum); err != nil {
return "", fmt.Errorf("fetch %s: %w", f.name, err)
}
}
return dir, nil
}
// downloadVerified streams url into dst.partial, verifies the SHA-256,
// and renames into place atomically.
func downloadVerified(client *http.Client, url, dst, wantSHA string) error {
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
tmp := dst + ".partial"
out, err := os.Create(tmp)
if err != nil {
return err
}
h := sha256.New()
_, cpErr := io.Copy(io.MultiWriter(out, h), resp.Body)
closeErr := out.Close()
if cpErr != nil {
os.Remove(tmp)
return cpErr
}
if closeErr != nil {
os.Remove(tmp)
return closeErr
}
if got := hex.EncodeToString(h.Sum(nil)); got != wantSHA {
os.Remove(tmp)
return fmt.Errorf("checksum mismatch: got %s want %s", got, wantSHA)
}
return os.Rename(tmp, dst)
}
func fileSHA256Matches(path, want string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return false, err
}
return hex.EncodeToString(h.Sum(nil)) == want, nil
}