mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
ci(gallery): propose variant groupings for review on a schedule A gallery entry may declare `variants:`, references to other entries that are alternative builds of the same weights, and auto-selection then installs the best build for the host. Those families exist only because humans curated them in two manual sweeps. The gallery agent dedupes on the HuggingFace repo URL and picks one quantization per model, so it never adds a second build of a repo it already has, and consequently never creates a family and never joins one. A model published across two repos lands as two unrelated standalone entries. The grouping decays as the gallery grows and nothing notices. Add a scheduled job, in the same shape as the checksum checker: compute offline, edit the index textually, open a pull request against ci-forks. It proposes and never decides. Grouping is a judgement call that has gone wrong in both directions, so the value is catching drift and surfacing candidates with their evidence. Three grouping signals, taken from the manual sweeps: same name once quantization markers are stripped, the `:` config-suffix convention, and the same primary weight filename once quantization markers are stripped. The third requires the same upstream repository. Excluding auxiliary files is not enough on its own: bert-embeddings, an ultravox audio model and a roleplay finetune all declare a primary file called llama-3.2-1b-instruct-q4_k_m.gguf, and grouping on that is the same error that linked four wan-2.1 entries and Z-Image-Turbo to qwen3-4b. Add gallery/variant-exclusions.yaml, a checked-in rejection ledger. A job that re-proposes declined candidates every night becomes noise and gets ignored. Declining a proposal is one flow-mapping line a reviewer adds inside the proposal pull request itself. Seeded with the six -abliterated pairs whose base is also in the gallery, the mistral-small multimodal pair, the whisper-1 alias, the kokoros language set, and the recurring finetune tokens. qat and apex are deliberately not on it: they are quantization techniques. Proposals refuse to nest, to let two parents claim one target, to target an entry that installs nothing, and to touch a merge anchor, since a variants key added to an anchor is inherited by every merging child. The anchor refusal names every entry that would inherit, which is the worklist a human needs. Run against the pre-sweep gallery, the job rediscovers 12 of the 19 groupings the second manual sweep made, with no false positives. The rest it reports as refusals or ledger declines rather than missing silently. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
152 lines
5.4 KiB
Go
152 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Quantization and precision markers that distinguish one build of a set of
|
|
// weights from another build of the same weights. Stripping them from a name
|
|
// is what lets the proposer notice that two entries are the same model.
|
|
//
|
|
// qat and apex are in this list on a maintainer ruling: they are quantization
|
|
// techniques applied to published weights, not separate weights. Names that use
|
|
// "apex" to mean a finetune are handled by the rejection ledger instead, because
|
|
// no amount of pattern matching can tell the two uses apart.
|
|
const quantAlternation = `q[2-8](?:_[0-9a-z]+)*|pq[2-8](?:_[0-9a-z]+)*|iq[1-9][0-9a-z]*(?:_[0-9a-z]+)*|i1|` +
|
|
`f16|f32|bf16|fp16|fp32|fp8|fp4|nvfp4|mxfp4(?:_moe)*|awq|gptq|qat|apex|gguf|ggml|[0-9]+bit|g[0-9]+`
|
|
|
|
// quantSegment matches a whole hyphen-delimited segment of an entry name.
|
|
// Names separate their parts with "-" and keep quantization tokens internally
|
|
// joined with "_", so a segment is the right unit here: "q4_k_m" arrives whole.
|
|
var quantSegment = regexp.MustCompile(`^(?:` + quantAlternation + `)$`)
|
|
|
|
// quantFileSuffix matches a trailing quantization token in a weight filename.
|
|
// Filenames mix "-", "_" and "." as separators, so unlike entry names they
|
|
// cannot be split into segments up front without tearing "Q4_K_M" apart.
|
|
var quantFileSuffix = regexp.MustCompile(`(?i)[-_.](?:` + quantAlternation + `)$`)
|
|
|
|
var weightExtension = regexp.MustCompile(`(?i)\.(gguf|ggml|safetensors|bin|pt|pth|onnx)$`)
|
|
|
|
// IsQuantToken reports whether a single name segment is a quantization or
|
|
// precision marker rather than part of the model's identity.
|
|
func IsQuantToken(segment string) bool {
|
|
return quantSegment.MatchString(strings.ToLower(segment))
|
|
}
|
|
|
|
// NameStem reduces an entry name to the identity it shares with its alternative
|
|
// builds: the config suffix after ":" is dropped, then trailing quantization
|
|
// segments are stripped.
|
|
//
|
|
// It implements the first two grouping signals together because they answer the
|
|
// same question. "foo:q8_0" and "foo-q8_0" are both alternative builds of "foo",
|
|
// and the caller that needs to report which convention was used can compare the
|
|
// name against the stem itself.
|
|
//
|
|
// At least one segment always survives, so a name made entirely of quantization
|
|
// tokens does not collapse to the empty stem and swallow every other such name.
|
|
func NameStem(name string) string {
|
|
base := strings.ToLower(strings.TrimSpace(name))
|
|
if i := strings.Index(base, ":"); i >= 0 {
|
|
base = base[:i]
|
|
}
|
|
segments := strings.Split(base, "-")
|
|
for len(segments) > 1 && quantSegment.MatchString(segments[len(segments)-1]) {
|
|
segments = segments[:len(segments)-1]
|
|
}
|
|
return strings.Join(segments, "-")
|
|
}
|
|
|
|
// HasConfigSuffix reports whether a name uses the ":" convention for naming a
|
|
// config variant of another entry.
|
|
func HasConfigSuffix(name string) bool {
|
|
return strings.Contains(name, ":")
|
|
}
|
|
|
|
// FileStem reduces a weight filename to the identity shared by its other
|
|
// quantizations: directories, extension and trailing quantization tokens go.
|
|
//
|
|
// This is the third grouping signal. It is the one that has misfired before, so
|
|
// callers must filter auxiliary files out before handing a filename here: a
|
|
// shared text encoder is not evidence of shared weights.
|
|
func FileStem(filename string) string {
|
|
base := filename
|
|
if i := strings.LastIndex(base, "/"); i >= 0 {
|
|
base = base[i+1:]
|
|
}
|
|
base = weightExtension.ReplaceAllString(base, "")
|
|
for {
|
|
stripped := quantFileSuffix.ReplaceAllString(base, "")
|
|
if stripped == base {
|
|
break
|
|
}
|
|
base = stripped
|
|
}
|
|
return strings.ToLower(base)
|
|
}
|
|
|
|
// bitsPerWeight ranks quantization tokens so the smallest build of a family can
|
|
// be identified when no bare-named entry exists to be the parent.
|
|
//
|
|
// The figures are nominal bits per weight, not measured file sizes. Ranking is
|
|
// all that is asked of them, and a nominal figure is available from the name
|
|
// alone without downloading anything.
|
|
func bitsPerWeight(token string) (int, bool) {
|
|
t := strings.ToLower(token)
|
|
switch {
|
|
case t == "i1":
|
|
return 1, true
|
|
case strings.HasPrefix(t, "nvfp4"), strings.HasPrefix(t, "mxfp4"), t == "fp4":
|
|
return 4, true
|
|
case t == "fp8":
|
|
return 8, true
|
|
case t == "f16", t == "bf16", t == "fp16":
|
|
return 16, true
|
|
case t == "f32", t == "fp32":
|
|
return 32, true
|
|
case t == "awq", t == "gptq":
|
|
return 4, true
|
|
}
|
|
if m := regexp.MustCompile(`^p?q([1-9])`).FindStringSubmatch(t); m != nil {
|
|
n, _ := strconv.Atoi(m[1])
|
|
return n, true
|
|
}
|
|
if m := regexp.MustCompile(`^iq([1-9])`).FindStringSubmatch(t); m != nil {
|
|
n, _ := strconv.Atoi(m[1])
|
|
return n, true
|
|
}
|
|
if m := regexp.MustCompile(`^([0-9]+)bit$`).FindStringSubmatch(t); m != nil {
|
|
n, _ := strconv.Atoi(m[1])
|
|
return n, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// unknownWidth sorts after every recognised quantization so an entry whose
|
|
// build cannot be read from its filename never wins the "smallest build" tie
|
|
// break by accident.
|
|
const unknownWidth = 1 << 10
|
|
|
|
// BuildWidth reports the nominal bits per weight of the build a filename holds.
|
|
// An unreadable filename gets unknownWidth.
|
|
func BuildWidth(filename string) int {
|
|
base := filename
|
|
if i := strings.LastIndex(base, "/"); i >= 0 {
|
|
base = base[i+1:]
|
|
}
|
|
base = weightExtension.ReplaceAllString(base, "")
|
|
best := unknownWidth
|
|
for {
|
|
m := quantFileSuffix.FindString(base)
|
|
if m == "" {
|
|
break
|
|
}
|
|
if bits, ok := bitsPerWeight(m[1:]); ok && bits < best {
|
|
best = bits
|
|
}
|
|
base = base[:len(base)-len(m)]
|
|
}
|
|
return best
|
|
}
|