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>
181 lines
5.1 KiB
Go
181 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Ledger records the grouping decisions a human has already made against the
|
|
// proposer, so a declined candidate stays declined instead of coming back every
|
|
// night until reviewers stop reading the job's pull requests.
|
|
//
|
|
// It is checked in next to the gallery and is meant to be edited inside the
|
|
// proposal pull request itself: declining a family is adding one flow-mapping
|
|
// line under pairs or groups and closing the PR.
|
|
type Ledger struct {
|
|
// Tokens are name segments that mark a distinct model rather than another
|
|
// build of the same one: finetune names, language codes, product suffixes.
|
|
// A candidate whose two names differ by any of these is never proposed.
|
|
Tokens []LedgerToken `yaml:"tokens"`
|
|
// Pairs are individual candidates a human considered and declined. Order
|
|
// does not matter: the pair is matched both ways round.
|
|
Pairs []LedgerPair `yaml:"pairs"`
|
|
// Groups decline every pair drawn from a set at once, for families like a
|
|
// per-language release where listing each pair would be unreadable.
|
|
Groups []LedgerGroup `yaml:"groups"`
|
|
}
|
|
|
|
type LedgerToken struct {
|
|
Token string `yaml:"token"`
|
|
Reason string `yaml:"reason"`
|
|
}
|
|
|
|
type LedgerPair struct {
|
|
Parent string `yaml:"parent"`
|
|
Variant string `yaml:"variant"`
|
|
Reason string `yaml:"reason"`
|
|
}
|
|
|
|
type LedgerGroup struct {
|
|
Members []string `yaml:"members"`
|
|
Reason string `yaml:"reason"`
|
|
}
|
|
|
|
// LoadLedger reads a ledger file. A missing file is not an error: a gallery
|
|
// that has declined nothing yet is a legitimate state, and failing the job over
|
|
// it would only teach people to keep an empty file around.
|
|
func LoadLedger(path string) (*Ledger, error) {
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return &Ledger{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseLedger(data)
|
|
}
|
|
|
|
func ParseLedger(data []byte) (*Ledger, error) {
|
|
l := &Ledger{}
|
|
if err := yaml.Unmarshal(data, l); err != nil {
|
|
return nil, fmt.Errorf("parsing ledger: %w", err)
|
|
}
|
|
for i, t := range l.Tokens {
|
|
if strings.TrimSpace(t.Token) == "" {
|
|
return nil, fmt.Errorf("ledger tokens[%d] has an empty token", i)
|
|
}
|
|
}
|
|
for i, p := range l.Pairs {
|
|
if strings.TrimSpace(p.Parent) == "" || strings.TrimSpace(p.Variant) == "" {
|
|
return nil, fmt.Errorf("ledger pairs[%d] needs both parent and variant", i)
|
|
}
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
// Suppression is a ledger hit: why a candidate was not proposed, in words a
|
|
// reviewer can check against the ledger file.
|
|
type Suppression struct {
|
|
A string
|
|
B string
|
|
Reason string
|
|
}
|
|
|
|
func (s Suppression) String() string {
|
|
return fmt.Sprintf("%s + %s: %s", s.A, s.B, s.Reason)
|
|
}
|
|
|
|
// Suppresses reports whether the ledger has already declined pairing these two
|
|
// entries, and why.
|
|
//
|
|
// The token rule is applied to the segments the two names do not share. Two
|
|
// builds of the same weights differ only in quantization markers, so any
|
|
// ledgered token showing up in that difference is by construction a claim that
|
|
// the entries are different models.
|
|
func (l *Ledger) Suppresses(a, b string) (Suppression, bool) {
|
|
la, lb := strings.ToLower(a), strings.ToLower(b)
|
|
for _, p := range l.Pairs {
|
|
lp, lv := strings.ToLower(p.Parent), strings.ToLower(p.Variant)
|
|
if (lp == la && lv == lb) || (lp == lb && lv == la) {
|
|
return Suppression{A: a, B: b, Reason: p.Reason}, true
|
|
}
|
|
}
|
|
for _, g := range l.Groups {
|
|
var seenA, seenB bool
|
|
for _, m := range g.Members {
|
|
lm := strings.ToLower(m)
|
|
if lm == la {
|
|
seenA = true
|
|
}
|
|
if lm == lb {
|
|
seenB = true
|
|
}
|
|
}
|
|
if seenA && seenB {
|
|
return Suppression{A: a, B: b, Reason: g.Reason}, true
|
|
}
|
|
}
|
|
diff := differingSegments(la, lb)
|
|
for _, t := range l.Tokens {
|
|
token := strings.ToLower(strings.TrimSpace(t.Token))
|
|
if _, ok := diff[token]; ok {
|
|
reason := t.Reason
|
|
if reason == "" {
|
|
reason = fmt.Sprintf("names differ by %q", token)
|
|
}
|
|
return Suppression{A: a, B: b, Reason: fmt.Sprintf("%s (token %q)", reason, token)}, true
|
|
}
|
|
}
|
|
return Suppression{}, false
|
|
}
|
|
|
|
// segments splits a name into the atoms the token rules are written against.
|
|
func segments(name string) []string {
|
|
fields := strings.FieldsFunc(strings.ToLower(name), func(r rune) bool {
|
|
return r == '-' || r == '_' || r == '.' || r == ':' || r == '/'
|
|
})
|
|
return fields
|
|
}
|
|
|
|
// differingSegments returns the set of segments present in exactly one of the
|
|
// two names.
|
|
func differingSegments(a, b string) map[string]struct{} {
|
|
setA := map[string]int{}
|
|
for _, s := range segments(a) {
|
|
setA[s]++
|
|
}
|
|
setB := map[string]int{}
|
|
for _, s := range segments(b) {
|
|
setB[s]++
|
|
}
|
|
diff := map[string]struct{}{}
|
|
for s := range setA {
|
|
if setB[s] == 0 {
|
|
diff[s] = struct{}{}
|
|
}
|
|
}
|
|
for s := range setB {
|
|
if setA[s] == 0 {
|
|
diff[s] = struct{}{}
|
|
}
|
|
}
|
|
return diff
|
|
}
|
|
|
|
// SortedSuppressions gives the ledger's effect on one run in a stable order, so
|
|
// the pull request body reads the same way for the same gallery.
|
|
func SortedSuppressions(in []Suppression) []Suppression {
|
|
out := append([]Suppression(nil), in...)
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].A != out[j].A {
|
|
return out[i].A < out[j].A
|
|
}
|
|
return out[i].B < out[j].B
|
|
})
|
|
return out
|
|
}
|