35193f82f1
Add a new swap matrix to supersede groups for running concurrent models. The matrix uses a solver that picks the lowest cost evictions to make a requested model available. This simple approach along with a very basic DSL grammar can enable very complex swapping scenarios. - add DSL parser for set expressions with & (AND), | (OR), (), +ref - add MatrixConfig structs, validation, and topological sort for +ref - add MatrixSolver with cost-minimizing swap decisions - add Matrix runtime integrating solver with Process lifecycle - integrate matrix into ProxyManager with if-branches at all endpoints - update config.example.yaml and config-schema.json with matrix schema - config enforces groups XOR matrix (cannot use both) fixes #643
228 lines
6.2 KiB
Go
228 lines
6.2 KiB
Go
package proxy
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/mostlygeek/llama-swap/proxy/config"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Helper to build expanded sets for solver tests
|
|
func makeExpandedSets(sets ...struct {
|
|
name string
|
|
models []string
|
|
}) []config.ExpandedSet {
|
|
var result []config.ExpandedSet
|
|
for _, s := range sets {
|
|
result = append(result, config.ExpandedSet{
|
|
SetName: s.name,
|
|
Models: s.models,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func es(name string, models ...string) struct {
|
|
name string
|
|
models []string
|
|
} {
|
|
return struct {
|
|
name string
|
|
models []string
|
|
}{name, models}
|
|
}
|
|
|
|
func TestMatrixSolver_AlreadyRunning(t *testing.T) {
|
|
solver := NewMatrixSolver(
|
|
makeExpandedSets(es("s1", "a", "b")),
|
|
nil,
|
|
)
|
|
|
|
result, err := solver.Solve("a", []string{"a"})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Evict)
|
|
assert.Equal(t, []string{"a"}, result.TargetSet)
|
|
assert.Equal(t, "s1", result.SetName)
|
|
}
|
|
|
|
func TestMatrixSolver_NotInAnySet_RunsAlone(t *testing.T) {
|
|
solver := NewMatrixSolver(
|
|
makeExpandedSets(es("s1", "a", "b")),
|
|
nil,
|
|
)
|
|
|
|
// Model "c" not in any set
|
|
result, err := solver.Solve("c", []string{"a", "b"})
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"a", "b"}, result.Evict)
|
|
assert.Equal(t, []string{"c"}, result.TargetSet)
|
|
}
|
|
|
|
func TestMatrixSolver_NotInAnySet_NothingRunning(t *testing.T) {
|
|
solver := NewMatrixSolver(
|
|
makeExpandedSets(es("s1", "a", "b")),
|
|
nil,
|
|
)
|
|
|
|
result, err := solver.Solve("c", []string{})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Evict)
|
|
assert.Equal(t, []string{"c"}, result.TargetSet)
|
|
}
|
|
|
|
func TestMatrixSolver_SingleSet_EvictsNonMembers(t *testing.T) {
|
|
// Set: [a, b]. Request a when b and c are running.
|
|
solver := NewMatrixSolver(
|
|
makeExpandedSets(es("s1", "a", "b")),
|
|
nil,
|
|
)
|
|
|
|
result, err := solver.Solve("a", []string{"b", "c"})
|
|
require.NoError(t, err)
|
|
// c is not in the set, so it gets evicted. b is in the set, so it stays.
|
|
assert.Equal(t, []string{"c"}, result.Evict)
|
|
assert.Equal(t, []string{"a", "b"}, result.TargetSet)
|
|
}
|
|
|
|
func TestMatrixSolver_PicksLowestCost(t *testing.T) {
|
|
// Two sets containing model "a":
|
|
// s1: [a, v] — if v is running, cost=0; if L is running, cost=30
|
|
// s2: [a, L] — if L is running, cost=0; if v is running, cost=50
|
|
solver := NewMatrixSolver(
|
|
makeExpandedSets(
|
|
es("s1", "a", "v"),
|
|
es("s2", "a", "L"),
|
|
),
|
|
map[string]int{"v": 50, "L": 30},
|
|
)
|
|
|
|
// v is running. Switching to a:
|
|
// s1 cost: v is in s1, so 0
|
|
// s2 cost: v is NOT in s2, so 50
|
|
// => pick s1
|
|
result, err := solver.Solve("a", []string{"v"})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Evict)
|
|
assert.Equal(t, []string{"a", "v"}, result.TargetSet)
|
|
|
|
// L is running. Switching to a:
|
|
// s1 cost: L is NOT in s1, so 30
|
|
// s2 cost: L is in s2, so 0
|
|
// => pick s2
|
|
result, err = solver.Solve("a", []string{"L"})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Evict)
|
|
assert.Equal(t, []string{"a", "L"}, result.TargetSet)
|
|
}
|
|
|
|
func TestMatrixSolver_TieBreakingByDefinitionOrder(t *testing.T) {
|
|
// Two sets with identical cost. Definition order should win.
|
|
solver := NewMatrixSolver(
|
|
makeExpandedSets(
|
|
es("s1", "a", "x"),
|
|
es("s2", "a", "y"),
|
|
),
|
|
nil,
|
|
)
|
|
|
|
// Nothing running, both sets cost 0. s1 is first.
|
|
result, err := solver.Solve("a", []string{})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Evict)
|
|
assert.Equal(t, []string{"a", "x"}, result.TargetSet)
|
|
}
|
|
|
|
func TestMatrixSolver_EvictCostPreservesExpensive(t *testing.T) {
|
|
// Model "v" costs 50 to evict, "m" costs 1 (default).
|
|
// Sets: [g,v], [g,m]
|
|
// Running: v, m. Request g.
|
|
// s1=[g,v]: evict m (cost 1), keep v
|
|
// s2=[g,m]: evict v (cost 50), keep m
|
|
// => pick s1
|
|
solver := NewMatrixSolver(
|
|
makeExpandedSets(
|
|
es("s1", "g", "v"),
|
|
es("s2", "g", "m"),
|
|
),
|
|
map[string]int{"v": 50},
|
|
)
|
|
|
|
result, err := solver.Solve("g", []string{"v", "m"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"m"}, result.Evict)
|
|
assert.Equal(t, []string{"g", "v"}, result.TargetSet)
|
|
}
|
|
|
|
func TestMatrixSolver_NothingRunning(t *testing.T) {
|
|
solver := NewMatrixSolver(
|
|
makeExpandedSets(
|
|
es("s1", "g", "v"),
|
|
es("s2", "q", "v"),
|
|
),
|
|
nil,
|
|
)
|
|
|
|
result, err := solver.Solve("g", []string{})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Evict)
|
|
assert.Equal(t, []string{"g", "v"}, result.TargetSet)
|
|
}
|
|
|
|
func TestMatrixSolver_FullScenario(t *testing.T) {
|
|
// Simulates the example config:
|
|
// standard: [g,v], [q,v], [m,v]
|
|
// with_rerank: [g,v,e], [q,v,e]
|
|
// creative: [g,sd], [q,sd]
|
|
// full: [L]
|
|
solver := NewMatrixSolver(
|
|
makeExpandedSets(
|
|
es("standard", "g", "v"),
|
|
es("standard", "q", "v"),
|
|
es("standard", "m", "v"),
|
|
es("with_rerank", "e", "g", "v"),
|
|
es("with_rerank", "e", "q", "v"),
|
|
es("creative", "g", "sd"),
|
|
es("creative", "q", "sd"),
|
|
es("full", "L"),
|
|
),
|
|
map[string]int{"v": 50, "L": 30, "whisper": 10},
|
|
)
|
|
|
|
// Running: g, v. Request q.
|
|
// standard[q,v]: evict g (cost 1), keep v. Total: 1.
|
|
// with_rerank[q,v,e]: evict g (cost 1), keep v. Total: 1.
|
|
// => tie, pick first by definition order = standard[q,v]
|
|
result, err := solver.Solve("q", []string{"g", "v"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"g"}, result.Evict)
|
|
assert.Equal(t, []string{"q", "v"}, result.TargetSet)
|
|
|
|
// Running: g, v. Request L.
|
|
// full[L]: evict g (cost 1) + v (cost 50). Total: 51.
|
|
// Only one set contains L, so pick it.
|
|
result, err = solver.Solve("L", []string{"g", "v"})
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"g", "v"}, result.Evict)
|
|
assert.Equal(t, []string{"L"}, result.TargetSet)
|
|
|
|
// Running: g, v. Request sd.
|
|
// creative[g,sd]: evict v (cost 50). Total: 50.
|
|
// creative[q,sd]: evict g (cost 1) + v (cost 50). Total: 51.
|
|
// => pick creative[g,sd]
|
|
result, err = solver.Solve("sd", []string{"g", "v"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"v"}, result.Evict)
|
|
assert.Equal(t, []string{"g", "sd"}, result.TargetSet)
|
|
|
|
// Running: q, v, e. Request g.
|
|
// standard[g,v]: evict q (1) + e (1). Total: 2.
|
|
// with_rerank[g,v,e]: evict q (1). Total: 1.
|
|
// creative[g,sd]: evict q (1) + v (50) + e (1). Total: 52.
|
|
// => pick with_rerank[g,v,e]
|
|
result, err = solver.Solve("g", []string{"e", "q", "v"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"q"}, result.Evict)
|
|
assert.Equal(t, []string{"e", "g", "v"}, result.TargetSet)
|
|
}
|