From 48821a00c773c606e59c5dac000bee85c6acec55 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 18 Jul 2026 10:04:24 +0000 Subject: [PATCH] feat(gallery): add hardware-aware model variant resolver Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto --- core/gallery/resolve_candidate.go | 84 ++++++++++++++++++++++++++ core/gallery/resolve_candidate_test.go | 68 +++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 core/gallery/resolve_candidate.go diff --git a/core/gallery/resolve_candidate.go b/core/gallery/resolve_candidate.go new file mode 100644 index 000000000..c8574590a --- /dev/null +++ b/core/gallery/resolve_candidate.go @@ -0,0 +1,84 @@ +package gallery + +import ( + "errors" + "fmt" + "strings" +) + +var ( + // ErrNoCandidateMatch is returned when no candidate satisfies the host. + ErrNoCandidateMatch = errors.New("no model variant matches this system") + // ErrPinNotFound is returned when a pinned variant is absent from the list. + ErrPinNotFound = errors.New("pinned model variant not found") +) + +// ResolveEnv describes the host properties a candidate is matched against. +// +// It is a plain struct rather than a *SystemState so the resolver stays pure +// and testable across hardware shapes without touching the machine. +type ResolveEnv struct { + // Capability is the raw value from SystemState.DetectedCapability(). + Capability string + // VRAM is total available VRAM in bytes, already capped by the operator + // VRAM budget because the cap is applied inside detection. + VRAM uint64 +} + +// ResolveCandidate returns the first candidate the host satisfies, or the +// pinned candidate when pin is non-empty. +// +// Why first-match rather than best-match: the list order is the policy. It is +// authored deliberately and lint-checked, which keeps selection explainable +// (you can read the list top to bottom) and keeps fallback expressible without +// a scoring function nobody can predict. +func ResolveCandidate(candidates []Candidate, env ResolveEnv, pin string) (Candidate, error) { + if pin != "" { + for _, c := range candidates { + if strings.EqualFold(c.Model, pin) { + return c, nil + } + } + return Candidate{}, fmt.Errorf("%w: %q is not among the %d variants of this model", ErrPinNotFound, pin, len(candidates)) + } + + reasons := make([]string, 0, len(candidates)) + for _, c := range candidates { + if c.Capability != "" && c.Capability != env.Capability { + reasons = append(reasons, fmt.Sprintf("%s requires capability %q", c.Model, c.Capability)) + continue + } + + floor, declared, err := c.EffectiveMinVRAM() + if err != nil { + return Candidate{}, err + } + // A candidate with no declared floor states no requirement and + // passes. Lint guarantees only the final last-resort candidate is + // in that position for the official gallery. + if declared && env.VRAM < floor { + reasons = append(reasons, fmt.Sprintf("%s needs %s VRAM", c.Model, humanBytes(floor))) + continue + } + + return c, nil + } + + return Candidate{}, fmt.Errorf( + "%w: capability %q with %s VRAM available; candidates: %s", + ErrNoCandidateMatch, env.Capability, humanBytes(env.VRAM), strings.Join(reasons, "; "), + ) +} + +func humanBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%dB", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f%ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} diff --git a/core/gallery/resolve_candidate_test.go b/core/gallery/resolve_candidate_test.go index 77a48f3b5..4ecdc6238 100644 --- a/core/gallery/resolve_candidate_test.go +++ b/core/gallery/resolve_candidate_test.go @@ -38,3 +38,71 @@ var _ = Describe("Candidate.EffectiveMinVRAM", func() { Expect(err).To(HaveOccurred()) }) }) + +var _ = Describe("ResolveCandidate", func() { + gib := func(n uint64) uint64 { return n * 1024 * 1024 * 1024 } + + list := []gallery.Candidate{ + {Model: "m-mlx", Capability: "metal", MinVRAM: "16GiB"}, + {Model: "m-vllm", Capability: "nvidia", MinVRAM: "20GiB"}, + {Model: "m-q8", MinVRAM: "12GiB"}, + {Model: "m-q4", MinVRAM: "6GiB"}, + {Model: "m-q4-cpu"}, + } + + It("picks the capability-matched candidate when VRAM allows", func() { + got, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(24)}, "") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Model).To(Equal("m-vllm")) + }) + + It("skips a capability match whose VRAM floor does not fit", func() { + got, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(8)}, "") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Model).To(Equal("m-q4")) + }) + + It("ignores candidates whose capability does not match the host", func() { + got, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "metal", VRAM: gib(24)}, "") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Model).To(Equal("m-mlx")) + }) + + It("falls through to the unconstrained last resort on a tiny host", func() { + got, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "default", VRAM: gib(2)}, "") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Model).To(Equal("m-q4-cpu")) + }) + + It("honors a pin even when the host would have chosen otherwise", func() { + got, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(24)}, "m-q8") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Model).To(Equal("m-q8")) + }) + + It("fails loudly when the pin names an absent candidate", func() { + _, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(24)}, "m-gone") + Expect(err).To(MatchError(gallery.ErrPinNotFound)) + Expect(err.Error()).To(ContainSubstring("m-gone")) + }) + + It("reports the host state when nothing matches", func() { + constrained := []gallery.Candidate{{Model: "big", MinVRAM: "80GiB"}} + _, err := gallery.ResolveCandidate(constrained, gallery.ResolveEnv{Capability: "default", VRAM: gib(8)}, "") + Expect(err).To(MatchError(gallery.ErrNoCandidateMatch)) + Expect(err.Error()).To(ContainSubstring("default")) + Expect(err.Error()).To(ContainSubstring("big")) + }) + + It("propagates an unparseable floor instead of treating it as unconstrained", func() { + bad := []gallery.Candidate{{Model: "bad", MinVRAM: "lots"}} + _, err := gallery.ResolveCandidate(bad, gallery.ResolveEnv{Capability: "default", VRAM: gib(8)}, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("bad")) + }) + + It("rejects an empty candidate list", func() { + _, err := gallery.ResolveCandidate(nil, gallery.ResolveEnv{Capability: "default", VRAM: gib(8)}, "") + Expect(err).To(MatchError(gallery.ErrNoCandidateMatch)) + }) +})