diff --git a/internal/loki/backend_gguf.go b/internal/loki/backend_gguf.go new file mode 100644 index 0000000..9e20a77 --- /dev/null +++ b/internal/loki/backend_gguf.go @@ -0,0 +1,609 @@ +package loki + +import ( + "bufio" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "math/bits" + "os" + "path/filepath" + "strconv" + "strings" + "sync" +) + +// Lecteur des métadonnées GGUF. Plusieurs réglages du moteur dépendent de ce que +// le fichier contient VRAIMENT : un modèle qui embarque une tête MTP (prédiction +// de plusieurs jetons, vérifiée exactement par le modèle cible), le nombre de +// têtes KV par couche pour estimer le cache, une architecture hybride à état +// récurrent. Jusqu'ici Loki ne le savait qu'à travers le nom du fichier, ce qui +// ne prouve rien. +// +// On ne lit que l'en-tête, la section clé/valeur et la table des tenseurs : les +// poids eux-mêmes ne sont jamais touchés, quelques Mo au plus pour un modèle de +// 30 Go. Le fichier peut être en cours de téléchargement, tronqué, ou tout +// simplement pas un GGUF : chaque longueur lue est bornée par ce qui reste du +// fichier, et la moindre incohérence donne une erreur, jamais un plantage ni une +// allocation géante. Une erreur veut dire « on ne sait pas » : l'appelant reste +// sur le comportement prudent. + +// GGUFInfo résume ce que Loki retient d'un modèle GGUF. Les champs absents du +// fichier restent à zéro. +type GGUFInfo struct { + Version int // version du format (1 à 3) + Arch string // general.architecture + BlockCount int // .block_count + NextN int // .nextn_predict_layers (couches MTP annoncées) + ContextLength int // .context_length (contexte d'entraînement) + ExpertCount int // .expert_count (0 = modèle dense) + KeyLen int // .attention.key_length + ValLen int // .attention.value_length + // HeadCountKV : valeur unique, ou maximum sur les couches quand le fichier + // donne un tableau par couche (HeadCountKVLayers, nil sinon). Sur un hybride, + // les couches récurrentes y valent 0. + HeadCountKV int + HeadCountKVLayers []int + FullAttnInterval int // .full_attention_interval, si présent + Hybrid bool // au moins une clé .ssm.* : couches à état récurrent + // HasNextNTensor : le tenseur blk.{BlockCount-1}.nextn.eh_proj.weight existe + // dans l'une des tranches. C'est le test de llama.cpp lui-même pour reconnaître + // une tête MTP : la clé nextn_predict_layers seule ne suffit pas, certains GGUF + // la gardent alors que les tenseurs ont été retirés (tête publiée à part). + HasNextNTensor bool + TensorCount int // total sur toutes les tranches +} + +// Bornes de lecture. Elles sont larges devant les vrais modèles (une poignée de +// milliers de tenseurs, une cinquantaine de clés hors vocabulaire) et servent +// seulement à refuser un fichier aberrant avant d'allouer quoi que ce soit. +const ( + ggufMaxString = 64 << 20 // modèle de chat le plus long connu : quelques dizaines de Ko + ggufMaxKeyLen = 64 << 10 + ggufMaxTensorName = 4096 // GGML_MAX_NAME vaut 64 + ggufMaxKV = 1 << 20 + ggufMaxTensors = 1 << 22 + ggufMaxDims = 8 // GGML_MAX_DIMS vaut 4 + ggufMaxArrayDepth = 4 + ggufMaxLayerArray = 1 << 16 +) + +// Types de valeur GGUF (gguf.h). +const ( + ggufUint8 uint32 = iota + ggufInt8 + ggufUint16 + ggufInt16 + ggufUint32 + ggufInt32 + ggufFloat32 + ggufBool + ggufString + ggufArray + ggufUint64 + ggufInt64 + ggufFloat64 +) + +var errGGUFTruncated = errors.New("GGUF tronqué") + +// ggufScalarSize : taille en octets d'un type de taille fixe, 0 sinon. +func ggufScalarSize(t uint32) int64 { + switch t { + case ggufUint8, ggufInt8, ggufBool: + return 1 + case ggufUint16, ggufInt16: + return 2 + case ggufUint32, ggufInt32, ggufFloat32: + return 4 + case ggufUint64, ggufInt64, ggufFloat64: + return 8 + } + return 0 +} + +// ggufReader lit séquentiellement un GGUF en comptant ce qui reste du fichier : +// toute longueur annoncée au-delà est une incohérence, détectée AVANT de lire. +type ggufReader struct { + r *bufio.Reader + bo binary.ByteOrder + v1 bool // GGUF v1 : compteurs et longueurs sur 32 bits + size int64 // taille totale du fichier + left int64 // octets restants dans le fichier + buf [8]byte +} + +func (g *ggufReader) fixed(n int) ([]byte, error) { + if int64(n) > g.left { + return nil, errGGUFTruncated + } + b := g.buf[:n] + if _, err := io.ReadFull(g.r, b); err != nil { + return nil, errGGUFTruncated + } + g.left -= int64(n) + return b, nil +} + +func (g *ggufReader) u32() (uint32, error) { + b, err := g.fixed(4) + if err != nil { + return 0, err + } + return g.bo.Uint32(b), nil +} + +func (g *ggufReader) u64() (uint64, error) { + b, err := g.fixed(8) + if err != nil { + return 0, err + } + return g.bo.Uint64(b), nil +} + +// count lit un compteur ou une longueur : 32 bits en v1, 64 bits ensuite. +func (g *ggufReader) count() (uint64, error) { + if g.v1 { + v, err := g.u32() + return uint64(v), err + } + return g.u64() +} + +// countSize : taille sur disque d'un compteur, pour borner les tableaux de chaînes. +func (g *ggufReader) countSize() int64 { + if g.v1 { + return 4 + } + return 8 +} + +func (g *ggufReader) skip(n int64) error { + if n < 0 || n > g.left { + return errGGUFTruncated + } + for rest := n; rest > 0; { + step := min(rest, 1<<30) + if _, err := g.r.Discard(int(step)); err != nil { + return errGGUFTruncated + } + rest -= step + } + g.left -= n + return nil +} + +// str lit une chaîne d'au plus max octets. +func (g *ggufReader) str(max uint64) (string, error) { + n, err := g.count() + if err != nil { + return "", err + } + if n > max { + return "", fmt.Errorf("chaîne GGUF de %d octets", n) + } + if int64(n) > g.left { + return "", errGGUFTruncated + } + b := make([]byte, n) + if _, err := io.ReadFull(g.r, b); err != nil { + return "", errGGUFTruncated + } + g.left -= int64(n) + return string(b), nil +} + +func (g *ggufReader) skipStr() error { + n, err := g.count() + if err != nil { + return err + } + if n > ggufMaxString { + return fmt.Errorf("chaîne GGUF de %d octets", n) + } + return g.skip(int64(n)) +} + +// intVal lit une valeur entière (ou booléenne) de type t. ok vaut false pour les +// flottants, lus et ignorés : aucune des clés retenues n'en est un. +func (g *ggufReader) intVal(t uint32) (v int64, ok bool, err error) { + size := ggufScalarSize(t) + if size == 0 { + return 0, false, fmt.Errorf("type GGUF %d inattendu", t) + } + b, err := g.fixed(int(size)) + if err != nil { + return 0, false, err + } + switch t { + case ggufUint8, ggufBool: + return int64(b[0]), true, nil + case ggufInt8: + return int64(int8(b[0])), true, nil + case ggufUint16: + return int64(g.bo.Uint16(b)), true, nil + case ggufInt16: + return int64(int16(g.bo.Uint16(b))), true, nil + case ggufUint32: + return int64(g.bo.Uint32(b)), true, nil + case ggufInt32: + return int64(int32(g.bo.Uint32(b))), true, nil + case ggufUint64: + u := g.bo.Uint64(b) + if u > math.MaxInt64 { + u = math.MaxInt64 + } + return int64(u), true, nil + case ggufInt64: + return int64(g.bo.Uint64(b)), true, nil + } + return 0, false, nil // flottant +} + +// skipArray saute n éléments de type et, tableaux imbriqués compris (profondeur +// bornée). Le vocabulaire (150 000 chaînes) passe par ici sans rien allouer. +func (g *ggufReader) skipArray(et uint32, n uint64, depth int) error { + if depth > ggufMaxArrayDepth { + return errors.New("tableaux GGUF trop imbriqués") + } + if size := ggufScalarSize(et); size > 0 { + if n > uint64(g.left/size) { + return errGGUFTruncated + } + return g.skip(int64(n) * size) + } + switch et { + case ggufString: + if n > uint64(g.left/g.countSize()) { + return errGGUFTruncated + } + for i := uint64(0); i < n; i++ { + if err := g.skipStr(); err != nil { + return err + } + } + return nil + case ggufArray: + // Chaque sous-tableau coûte au moins son type et son compteur. + if n > uint64(g.left/(4+g.countSize())) { + return errGGUFTruncated + } + for i := uint64(0); i < n; i++ { + sub, err := g.u32() + if err != nil { + return err + } + sn, err := g.count() + if err != nil { + return err + } + if err := g.skipArray(sub, sn, depth+1); err != nil { + return err + } + } + return nil + } + return fmt.Errorf("type GGUF %d inconnu", et) +} + +// ggufFile : ce qu'on retient d'UNE tranche avant de résoudre les clés selon +// l'architecture (general.architecture n'est pas forcément la première clé). +type ggufFile struct { + version int + arch string + ints map[string]int64 + arrays map[string][]int64 // tableaux d'entiers retenus (têtes KV par couche) + keys []string // noms de toutes les clés, pour repérer .ssm.* + tensors int + nextn []string // noms des tenseurs *.nextn.eh_proj.weight +} + +// keepIntArray : les seuls tableaux qu'on lit au lieu de les sauter. +func keepIntArray(key string) bool { + return strings.HasSuffix(key, ".attention.head_count_kv") +} + +// readGGUFHeader lit l'en-tête, les clés et la table des tenseurs d'un fichier. +func readGGUFHeader(path string) (*ggufFile, error) { + fh, err := os.Open(path) + if err != nil { + return nil, err + } + defer fh.Close() + st, err := fh.Stat() + if err != nil { + return nil, err + } + if st.IsDir() { + return nil, fmt.Errorf("%s est un dossier", baseName(path)) + } + f, err := parseGGUF(fh, st.Size()) + if err != nil { + return nil, fmt.Errorf("%s : %w", baseName(path), err) + } + return f, nil +} + +// parseGGUF lit size octets au plus de r. Un panic éventuel (fichier +// pathologique non prévu par les bornes) est rattrapé en erreur : lire des +// métadonnées ne doit jamais faire tomber Loki. +func parseGGUF(r io.Reader, size int64) (f *ggufFile, err error) { + defer func() { + if p := recover(); p != nil { + f, err = nil, fmt.Errorf("GGUF illisible : %v", p) + } + }() + g := &ggufReader{r: bufio.NewReaderSize(r, 1<<20), bo: binary.LittleEndian, size: size, left: size} + magic, err := g.fixed(4) + if err != nil { + return nil, err + } + if string(magic) != "GGUF" { + return nil, errors.New("pas un fichier GGUF") + } + ver, err := g.u32() + if err != nil { + return nil, err + } + // Les GGUF gros-boutistes (s390x) gardent la même signature : seule la + // version lue à l'envers trahit l'ordre des octets. + if ver&0xFFFF == 0 { + if sw := bits.ReverseBytes32(ver); sw >= 1 && sw <= 3 { + g.bo, ver = binary.BigEndian, sw + } + } + if ver < 1 || ver > 3 { + return nil, fmt.Errorf("version GGUF %d non prise en charge", ver) + } + g.v1 = ver == 1 + nTensors, err := g.count() + if err != nil { + return nil, err + } + nKV, err := g.count() + if err != nil { + return nil, err + } + if nKV > ggufMaxKV || nTensors > ggufMaxTensors { + return nil, fmt.Errorf("compteurs GGUF aberrants (%d clés, %d tenseurs)", nKV, nTensors) + } + f = &ggufFile{version: int(ver), ints: map[string]int64{}, arrays: map[string][]int64{}, tensors: int(nTensors)} + + for i := uint64(0); i < nKV; i++ { + key, err := g.str(ggufMaxKeyLen) + if err != nil { + return nil, err + } + t, err := g.u32() + if err != nil { + return nil, err + } + f.keys = append(f.keys, key) + switch t { + case ggufString: + if key == "general.architecture" { + if f.arch, err = g.str(ggufMaxKeyLen); err != nil { + return nil, err + } + } else if err := g.skipStr(); err != nil { + return nil, err + } + case ggufArray: + et, err := g.u32() + if err != nil { + return nil, err + } + n, err := g.count() + if err != nil { + return nil, err + } + if keepIntArray(key) && et != ggufFloat32 && et != ggufFloat64 && ggufScalarSize(et) > 0 && n <= ggufMaxLayerArray { + vals := make([]int64, 0, min(n, uint64(g.left))) + for j := uint64(0); j < n; j++ { + v, _, err := g.intVal(et) + if err != nil { + return nil, err + } + vals = append(vals, v) + } + f.arrays[key] = vals + } else if err := g.skipArray(et, n, 1); err != nil { + return nil, err + } + default: + v, ok, err := g.intVal(t) + if err != nil { + return nil, err + } + if ok { + f.ints[key] = v + } + } + } + + dimSize := int64(8) + if g.v1 { + dimSize = 4 + } + var maxOff uint64 + for i := uint64(0); i < nTensors; i++ { + name, err := g.str(ggufMaxTensorName) + if err != nil { + return nil, err + } + nd, err := g.u32() + if err != nil { + return nil, err + } + if nd > ggufMaxDims { + return nil, fmt.Errorf("tenseur %q à %d dimensions", name, nd) + } + // dimensions et type (u32), puis position des données (u64) + if err := g.skip(int64(nd)*dimSize + 4); err != nil { + return nil, err + } + off, err := g.u64() + if err != nil { + return nil, err + } + maxOff = max(maxOff, off) + if strings.HasSuffix(name, ".nextn.eh_proj.weight") { + f.nextn = append(f.nextn, name) + } + } + + // Les données commencent au prochain multiple de general.alignment. Le début + // du dernier tenseur doit tenir dans le fichier : un GGUF coupé dans ses poids + // (copie interrompue, disque plein) a un en-tête intact, il faut le voir + // quand même. On ne connaît pas la taille de chaque tenseur sans la table des + // types ggml : vérifier son premier octet suffit à attraper l'essentiel. + align := int64(32) + if a, ok := f.ints["general.alignment"]; ok { + if a <= 0 || a > 1<<20 || a&(a-1) != 0 { + return nil, fmt.Errorf("alignement GGUF %d invalide", a) + } + align = a + } + if nTensors > 0 { + pos := g.size - g.left + dataStart := (pos + align - 1) / align * align + if maxOff >= uint64(g.size) || dataStart+int64(maxOff) >= g.size { + return nil, errGGUFTruncated + } + } + return f, nil +} + +// clampInt ramène une valeur de métadonnée dans [0, MaxInt32] : un champ négatif +// ou démesuré n'a pas de sens pour un compte de couches ou de têtes. +func clampInt(v int64) int { + if v < 0 { + return 0 + } + if v > math.MaxInt32 { + return math.MaxInt32 + } + return int(v) +} + +// ggufInfoFrom résout les clés de la première tranche selon l'architecture et +// cherche la tête MTP dans les tenseurs de toutes les tranches. +func ggufInfoFrom(first *ggufFile, all []*ggufFile) GGUFInfo { + in := GGUFInfo{Version: first.version, Arch: first.arch} + p := first.arch + "." + get := func(k string) int { return clampInt(first.ints[p+k]) } + in.BlockCount = get("block_count") + in.NextN = get("nextn_predict_layers") + in.ContextLength = get("context_length") + in.ExpertCount = get("expert_count") + in.KeyLen = get("attention.key_length") + in.ValLen = get("attention.value_length") + in.FullAttnInterval = get("full_attention_interval") + in.HeadCountKV = get("attention.head_count_kv") + if arr, ok := first.arrays[p+"attention.head_count_kv"]; ok { + in.HeadCountKVLayers = make([]int, len(arr)) + in.HeadCountKV = 0 + for i, v := range arr { + in.HeadCountKVLayers[i] = clampInt(v) + in.HeadCountKV = max(in.HeadCountKV, in.HeadCountKVLayers[i]) + } + } + if first.arch != "" { + for _, k := range first.keys { + if strings.HasPrefix(k, p+"ssm.") { + in.Hybrid = true + break + } + } + } + want := "" + if in.BlockCount > 0 { + want = "blk." + strconv.Itoa(in.BlockCount-1) + ".nextn.eh_proj.weight" + } + for _, f := range all { + in.TensorCount += f.tensors + for _, n := range f.nextn { + if n == want { + in.HasNextNTensor = true + } + } + } + return in +} + +// Cache par chemin : la même liste de modèles est consultée à chaque démarrage +// et à chaque affichage. L'empreinte (taille et date de chaque tranche) suffit à +// voir un fichier remplacé ou un téléchargement terminé ; les erreurs ne sont +// pas gardées, un fichier en cours de téléchargement sera relu une fois complet. +type ggufCacheEntry struct { + stamp string + info GGUFInfo +} + +var ( + ggufCacheMu sync.Mutex + ggufCache = map[string]ggufCacheEntry{} +) + +const ggufCacheMax = 256 + +// ggufMeta lit les métadonnées du modèle désigné par path. Pour un modèle en +// tranches, path peut désigner n'importe laquelle : les clés viennent de la +// première (les suivantes ne portent que split.*), les tenseurs de toutes. Une +// tranche manquante est une erreur : le modèle ne démarrerait pas, et on ne +// saurait pas dire si la tête MTP s'y trouvait. +func ggufMeta(path string) (GGUFInfo, error) { + path = filepath.Clean(path) + dir := filepath.Dir(path) + fam := shardFamily(path) + paths := make([]string, len(fam)) + var stamp strings.Builder + for i, n := range fam { + paths[i] = filepath.Join(dir, n) + st, err := os.Stat(paths[i]) + if err != nil { + return GGUFInfo{}, err + } + if st.IsDir() { + return GGUFInfo{}, fmt.Errorf("%s est un dossier", n) + } + fmt.Fprintf(&stamp, "%d:%d;", st.Size(), st.ModTime().UnixNano()) + } + key := paths[0] + + ggufCacheMu.Lock() + e, ok := ggufCache[key] + ggufCacheMu.Unlock() + if ok && e.stamp == stamp.String() { + return cloneGGUFInfo(e.info), nil + } + + files := make([]*ggufFile, 0, len(paths)) + for _, p := range paths { + f, err := readGGUFHeader(p) + if err != nil { + return GGUFInfo{}, err + } + files = append(files, f) + } + info := ggufInfoFrom(files[0], files) + + ggufCacheMu.Lock() + if len(ggufCache) >= ggufCacheMax { + ggufCache = map[string]ggufCacheEntry{} + } + ggufCache[key] = ggufCacheEntry{stamp: stamp.String(), info: info} + ggufCacheMu.Unlock() + return cloneGGUFInfo(info), nil +} + +// cloneGGUFInfo : l'appelant peut modifier le tableau par couche sans toucher au +// cache. +func cloneGGUFInfo(in GGUFInfo) GGUFInfo { + if in.HeadCountKVLayers != nil { + in.HeadCountKVLayers = append([]int(nil), in.HeadCountKVLayers...) + } + return in +} diff --git a/internal/loki/backend_gguf_test.go b/internal/loki/backend_gguf_test.go new file mode 100644 index 0000000..ec3eb8d --- /dev/null +++ b/internal/loki/backend_gguf_test.go @@ -0,0 +1,381 @@ +package loki + +import ( + "bytes" + "encoding/binary" + "math" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// ggufBuilder écrit un GGUF synthétique : en-tête, clés typées, table des +// tenseurs, sans aucune donnée de poids (le lecteur n'en lit jamais). +type ggufBuilder struct { + bo binary.ByteOrder + version uint32 + nKV int + nT int + kv bytes.Buffer + tensors bytes.Buffer +} + +func newGGUF() *ggufBuilder { return &ggufBuilder{bo: binary.LittleEndian, version: 3} } + +func (b *ggufBuilder) cnt(w *bytes.Buffer, n uint64) { + if b.version == 1 { + binary.Write(w, b.bo, uint32(n)) + } else { + binary.Write(w, b.bo, n) + } +} + +func (b *ggufBuilder) str(w *bytes.Buffer, s string) { + b.cnt(w, uint64(len(s))) + w.WriteString(s) +} + +func (b *ggufBuilder) key(k string, t uint32) { + b.nKV++ + b.str(&b.kv, k) + binary.Write(&b.kv, b.bo, t) +} + +func (b *ggufBuilder) u32(k string, v uint32) *ggufBuilder { + b.key(k, ggufUint32) + binary.Write(&b.kv, b.bo, v) + return b +} + +func (b *ggufBuilder) i64(k string, v int64) *ggufBuilder { + b.key(k, ggufInt64) + binary.Write(&b.kv, b.bo, v) + return b +} + +func (b *ggufBuilder) f32(k string, v float32) *ggufBuilder { + b.key(k, ggufFloat32) + binary.Write(&b.kv, b.bo, math.Float32bits(v)) + return b +} + +func (b *ggufBuilder) boolean(k string, v bool) *ggufBuilder { + b.key(k, ggufBool) + if v { + b.kv.WriteByte(1) + } else { + b.kv.WriteByte(0) + } + return b +} + +func (b *ggufBuilder) s(k, v string) *ggufBuilder { + b.key(k, ggufString) + b.str(&b.kv, v) + return b +} + +func (b *ggufBuilder) strArr(k string, vs []string) *ggufBuilder { + b.key(k, ggufArray) + binary.Write(&b.kv, b.bo, ggufString) + b.cnt(&b.kv, uint64(len(vs))) + for _, v := range vs { + b.str(&b.kv, v) + } + return b +} + +func (b *ggufBuilder) i32Arr(k string, vs []int32) *ggufBuilder { + b.key(k, ggufArray) + binary.Write(&b.kv, b.bo, ggufInt32) + b.cnt(&b.kv, uint64(len(vs))) + for _, v := range vs { + binary.Write(&b.kv, b.bo, v) + } + return b +} + +// nested : tableau de deux tableaux de u8, pour vérifier le saut récursif. +func (b *ggufBuilder) nested(k string) *ggufBuilder { + b.key(k, ggufArray) + binary.Write(&b.kv, b.bo, ggufArray) + b.cnt(&b.kv, 2) + for i := 0; i < 2; i++ { + binary.Write(&b.kv, b.bo, ggufUint8) + b.cnt(&b.kv, 3) + b.kv.Write([]byte{1, 2, 3}) + } + return b +} + +func (b *ggufBuilder) tensor(name string, dims ...uint64) *ggufBuilder { + b.str(&b.tensors, name) + binary.Write(&b.tensors, b.bo, uint32(len(dims))) + for _, d := range dims { + if b.version == 1 { + binary.Write(&b.tensors, b.bo, uint32(d)) + } else { + binary.Write(&b.tensors, b.bo, d) + } + } + binary.Write(&b.tensors, b.bo, uint32(0)) // type F32 + binary.Write(&b.tensors, b.bo, uint64(b.nT*32)) // position des données + b.nT++ + return b +} + +func (b *ggufBuilder) bytes() []byte { + var out bytes.Buffer + out.WriteString("GGUF") + binary.Write(&out, b.bo, b.version) + b.cnt(&out, uint64(b.nT)) + b.cnt(&out, uint64(b.nKV)) + out.Write(b.kv.Bytes()) + out.Write(b.tensors.Bytes()) + // bourrage jusqu'à l'alignement par défaut, puis 32 octets de « poids » par + // tenseur + if b.nT > 0 { + out.Write(make([]byte, (32-out.Len()%32)%32+32*b.nT)) + } + return out.Bytes() +} + +func writeGGUF(t *testing.T, dir, name string, data []byte) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, data, 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// qwenMTP : modèle hybride à 4 couches, têtes KV par couche (0 sur les couches +// récurrentes), tête MTP sur la dernière couche, vocabulaire à sauter. +func qwenMTP(b *ggufBuilder) *ggufBuilder { + return b.u32("general.alignment", 32). + strArr("tokenizer.ggml.tokens", []string{"", "a", "bb", strings.Repeat("c", 300)}). + s("general.architecture", "qwen35"). + s("general.name", "Qwen synthétique"). + u32("qwen35.block_count", 4). + u32("qwen35.nextn_predict_layers", 1). + i64("qwen35.context_length", 262144). + u32("qwen35.attention.key_length", 256). + u32("qwen35.attention.value_length", 256). + i32Arr("qwen35.attention.head_count_kv", []int32{0, 0, 0, 4}). + u32("qwen35.full_attention_interval", 4). + u32("qwen35.ssm.conv_kernel", 4). + f32("qwen35.rope.freq_base", 1e7). + boolean("tokenizer.ggml.add_bos_token", false). + nested("bidon.imbrique"). + tensor("token_embd.weight", 1024, 32000). + tensor("blk.0.attn_norm.weight", 1024). + tensor("blk.3.nextn.eh_proj.weight", 2048, 1024) +} + +func TestGGUFMetaQwenHybrideMTP(t *testing.T) { + p := writeGGUF(t, t.TempDir(), "qwen.gguf", qwenMTP(newGGUF()).bytes()) + got, err := ggufMeta(p) + if err != nil { + t.Fatal(err) + } + want := GGUFInfo{ + Version: 3, Arch: "qwen35", BlockCount: 4, NextN: 1, ContextLength: 262144, + KeyLen: 256, ValLen: 256, HeadCountKV: 4, HeadCountKVLayers: []int{0, 0, 0, 4}, + FullAttnInterval: 4, Hybrid: true, HasNextNTensor: true, TensorCount: 3, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("ggufMeta =\n%+v\nattendu\n%+v", got, want) + } +} + +// La clé nextn_predict_layers ne prouve rien : seul le tenseur de la DERNIÈRE +// couche compte, comme dans llama.cpp. +func TestGGUFMetaTeteMTPAbsenteOuMalPlacee(t *testing.T) { + dir := t.TempDir() + base := func() *ggufBuilder { + return newGGUF().s("general.architecture", "glm4moe"). + u32("glm4moe.block_count", 47). + u32("glm4moe.nextn_predict_layers", 1). + u32("glm4moe.expert_count", 128). + u32("glm4moe.attention.head_count_kv", 8) + } + cases := []struct { + name string + b *ggufBuilder + }{ + {"sans-tenseur.gguf", base().tensor("blk.45.ffn_up.weight", 8, 8)}, + {"mauvaise-couche.gguf", base().tensor("blk.45.nextn.eh_proj.weight", 8, 8)}, + } + for _, c := range cases { + got, err := ggufMeta(writeGGUF(t, dir, c.name, c.b.bytes())) + if err != nil { + t.Fatalf("%s : %v", c.name, err) + } + if got.HasNextNTensor || got.NextN != 1 || got.ExpertCount != 128 || got.HeadCountKV != 8 || + got.HeadCountKVLayers != nil || got.Hybrid { + t.Fatalf("%s : %+v", c.name, got) + } + } + got, err := ggufMeta(writeGGUF(t, dir, "ok.gguf", base().tensor("blk.46.nextn.eh_proj.weight", 8, 8).bytes())) + if err != nil || !got.HasNextNTensor { + t.Fatalf("tête MTP en dernière couche non vue : %+v, %v", got, err) + } +} + +// Modèle en tranches : les clés sont dans la première, la tête MTP dans la +// dernière. N'importe quelle tranche peut être désignée. +func TestGGUFMetaTranches(t *testing.T) { + dir := t.TempDir() + first := newGGUF().s("general.architecture", "qwen35").u32("qwen35.block_count", 4). + u32("split.count", 2).tensor("token_embd.weight", 8, 8) + second := newGGUF().u32("split.no", 1).u32("split.count", 2). + tensor("blk.3.nextn.eh_proj.weight", 8, 8).tensor("output.weight", 8, 8) + p1 := writeGGUF(t, dir, "m-00001-of-00002.gguf", first.bytes()) + writeGGUF(t, dir, "m-00002-of-00002.gguf", second.bytes()) + for _, p := range []string{p1, filepath.Join(dir, "m-00002-of-00002.gguf")} { + got, err := ggufMeta(p) + if err != nil { + t.Fatal(err) + } + if got.Arch != "qwen35" || !got.HasNextNTensor || got.TensorCount != 3 { + t.Fatalf("%s : %+v", baseName(p), got) + } + } + // tranche manquante : on ne sait pas, donc erreur + writeGGUF(t, dir, "n-00001-of-00002.gguf", first.bytes()) + if _, err := ggufMeta(filepath.Join(dir, "n-00001-of-00002.gguf")); err == nil { + t.Fatal("tranche manquante acceptée") + } +} + +// v1 (compteurs 32 bits) et gros-boutiste : même résultat que le v3 ordinaire. +func TestGGUFMetaVersionsEtOrdre(t *testing.T) { + dir := t.TempDir() + v1 := newGGUF() + v1.version = 1 + be := newGGUF() + be.bo = binary.BigEndian + for name, b := range map[string]*ggufBuilder{"v1.gguf": v1, "be.gguf": be} { + got, err := ggufMeta(writeGGUF(t, dir, name, qwenMTP(b).bytes())) + if err != nil { + t.Fatalf("%s : %v", name, err) + } + if got.Arch != "qwen35" || got.BlockCount != 4 || !got.HasNextNTensor || + !reflect.DeepEqual(got.HeadCountKVLayers, []int{0, 0, 0, 4}) || got.Version != int(b.version) { + t.Fatalf("%s : %+v", name, got) + } + } +} + +// Fichier en cours de téléchargement : chaque coupure donne une erreur, jamais +// un panic ni un faux résultat. +func TestGGUFMetaTronque(t *testing.T) { + dir := t.TempDir() + full := qwenMTP(newGGUF()).bytes() + // Toutes les coupures jusqu'au premier octet du dernier tenseur (le seul que + // le lecteur sait situer sans la table des types ggml). + for cut := 0; cut <= len(full)-32; cut++ { + if _, err := parseGGUF(bytes.NewReader(full[:cut]), int64(cut)); err == nil { + t.Fatalf("coupure à %d/%d acceptée", cut, len(full)) + } + } + if _, err := parseGGUF(bytes.NewReader(full), int64(len(full))); err != nil { + t.Fatalf("fichier complet refusé : %v", err) + } + // et par le chemin fichier, pour une coupure dans l'en-tête + if _, err := ggufMeta(writeGGUF(t, dir, "cut.gguf", full[:len(full)/3])); err == nil { + t.Fatal("fichier coupé accepté") + } +} + +func TestGGUFMetaFichiersAberrants(t *testing.T) { + dir := t.TempDir() + hdr := func(ver uint32, nT, nKV uint64) *bytes.Buffer { + var w bytes.Buffer + w.WriteString("GGUF") + binary.Write(&w, binary.LittleEndian, ver) + binary.Write(&w, binary.LittleEndian, nT) + binary.Write(&w, binary.LittleEndian, nKV) + return &w + } + // clé annoncée à 1 Eo + hugeKey := hdr(3, 0, 1) + binary.Write(hugeKey, binary.LittleEndian, uint64(1)<<60) + // tableau de chaînes annoncé à 2^62 éléments + hugeArr := hdr(3, 0, 1) + binary.Write(hugeArr, binary.LittleEndian, uint64(1)) + hugeArr.WriteString("k") + binary.Write(hugeArr, binary.LittleEndian, ggufArray) + binary.Write(hugeArr, binary.LittleEndian, ggufString) + binary.Write(hugeArr, binary.LittleEndian, uint64(1)<<62) + // type de valeur inconnu + badType := hdr(3, 0, 1) + binary.Write(badType, binary.LittleEndian, uint64(1)) + badType.WriteString("k") + binary.Write(badType, binary.LittleEndian, uint32(99)) + binary.Write(badType, binary.LittleEndian, uint64(0)) + // tenseur à 1000 dimensions + badDims := hdr(3, 1, 0) + binary.Write(badDims, binary.LittleEndian, uint64(1)) + badDims.WriteString("t") + binary.Write(badDims, binary.LittleEndian, uint32(1000)) + badDims.Write(make([]byte, 64)) + + cases := map[string][]byte{ + "vide": nil, + "alignement": newGGUF().u32("general.alignment", 24).tensor("t", 1).bytes(), + "signature": []byte("GGML\x03\x00\x00\x00" + strings.Repeat("\x00", 16)), + "version": hdr(4, 0, 0).Bytes(), + "compteurs": hdr(3, 1<<40, 1<<40).Bytes(), + "cle-geante": hugeKey.Bytes(), + "tableau": hugeArr.Bytes(), + "type-inconnu": badType.Bytes(), + "dimensions": badDims.Bytes(), + } + for name, data := range cases { + if _, err := ggufMeta(writeGGUF(t, dir, name+".gguf", data)); err == nil { + t.Errorf("%s : accepté", name) + } + } + if _, err := ggufMeta(filepath.Join(dir, "absent.gguf")); err == nil { + t.Error("fichier absent accepté") + } +} + +// Le cache tient tant que taille et date sont les mêmes, et se renouvelle dès +// qu'elles changent (téléchargement terminé, fichier remplacé). +func TestGGUFMetaCache(t *testing.T) { + dir := t.TempDir() + data := qwenMTP(newGGUF()).bytes() + p := writeGGUF(t, dir, "c.gguf", data) + stamp := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + if err := os.Chtimes(p, stamp, stamp); err != nil { + t.Fatal(err) + } + a, err := ggufMeta(p) + if err != nil { + t.Fatal(err) + } + a.HeadCountKVLayers[0] = 99 // ne doit pas atteindre le cache + + // même taille, même date, contenu illisible : le cache répond + writeGGUF(t, dir, "c.gguf", make([]byte, len(data))) + if err := os.Chtimes(p, stamp, stamp); err != nil { + t.Fatal(err) + } + b, err := ggufMeta(p) + if err != nil || b.HeadCountKVLayers[0] != 0 || !b.HasNextNTensor { + t.Fatalf("cache non utilisé ou altéré : %+v, %v", b, err) + } + + // date différente : relecture, donc erreur sur le contenu illisible + if err := os.Chtimes(p, stamp.Add(time.Second), stamp.Add(time.Second)); err != nil { + t.Fatal(err) + } + if _, err := ggufMeta(p); err == nil { + t.Fatal("fichier modifié servi depuis le cache") + } +}