From c2f1cb3d070356498bc034b19dad057390c28fa8 Mon Sep 17 00:00:00 2001 From: Wilson Wu Date: Sun, 4 Oct 2026 16:10:15 +0800 Subject: [PATCH 1/2] [Bug] Keep decision adaptations when the Builder deploys The Builder decompiles the config to DSL, compiles it back, and deploys the compiled routing in replace mode. The DSL cannot express decision adaptations, and mergeDSLOwnedNodes replaced routing and recipes with the compiled nodes, so a Deploy without edits removed every decision's adaptations: 4 decisions in the default config, 2 in the agent recipe and all 16 in the built-in mom-v1 recipe. Those decisions then fall back to mode apply, and Router Learning acts on routes that were set to bypass or observe it. Before the DSL-owned sections are replaced or merged, copy each base decision's adaptations into the compiled decision with the same name, matching recipes by name first. A value already in the compiled fragment wins. This is the rule dsl.MergeRoutingIntoBase applies when sr-dsl compiles with --base. Signed-off-by: Wilson Wu --- dashboard/backend/handlers/deploy.go | 1 + .../handlers/deploy_policy_preservation.go | 53 +++++ .../deploy_policy_preservation_test.go | 199 ++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 dashboard/backend/handlers/deploy_policy_preservation.go create mode 100644 dashboard/backend/handlers/deploy_policy_preservation_test.go diff --git a/dashboard/backend/handlers/deploy.go b/dashboard/backend/handlers/deploy.go index cf709892f60..5968843747a 100644 --- a/dashboard/backend/handlers/deploy.go +++ b/dashboard/backend/handlers/deploy.go @@ -396,6 +396,7 @@ func mergeDeployPayload(currentData []byte, req DeployRequest) ([]byte, error) { // Node-level merging makes the transport forward-compatible: a new signal or // projection is carried without adding another field-by-field merge branch. func mergeDSLOwnedNodes(baseRoot, fragmentRoot *yaml.Node, mode DeployMode) error { + preserveBaseDecisionAdaptations(baseRoot, fragmentRoot) fragmentRouting := mappingValueNode(fragmentRoot, "routing") if mode == DeployModeReplace { if fragmentRouting == nil { diff --git a/dashboard/backend/handlers/deploy_policy_preservation.go b/dashboard/backend/handlers/deploy_policy_preservation.go new file mode 100644 index 00000000000..c5cfcd4789b --- /dev/null +++ b/dashboard/backend/handlers/deploy_policy_preservation.go @@ -0,0 +1,53 @@ +package handlers + +import "gopkg.in/yaml.v3" + +// preserveBaseDecisionAdaptations copies each base decision's adaptations into +// the fragment decision with the same name, matching recipes by name first. +// The DSL cannot express adaptations, so compiled fragments never carry them; +// a value already in the fragment wins. dsl.MergeRoutingIntoBase applies the +// same rule for CLI compiles with a base config. +func preserveBaseDecisionAdaptations(baseRoot, fragmentRoot *yaml.Node) { + preserveDecisionAdaptations(mappingValueNode(baseRoot, "routing"), mappingValueNode(fragmentRoot, "routing")) + baseRecipes := sequenceItemsByName(mappingValueNode(baseRoot, "recipes")) + for _, recipe := range sequenceItems(mappingValueNode(fragmentRoot, "recipes")) { + baseRecipe := baseRecipes[itemName(recipe)] + preserveDecisionAdaptations(mappingValueNode(baseRecipe, "routing"), mappingValueNode(recipe, "routing")) + } +} + +func preserveDecisionAdaptations(baseRouting, fragmentRouting *yaml.Node) { + baseDecisions := sequenceItemsByName(mappingValueNode(baseRouting, "decisions")) + for _, decision := range sequenceItems(mappingValueNode(fragmentRouting, "decisions")) { + if mappingValueNode(decision, "adaptations") != nil { + continue + } + if adaptations := mappingValueNode(baseDecisions[itemName(decision)], "adaptations"); adaptations != nil { + setMappingValueNode(decision, "adaptations", adaptations) + } + } +} + +func sequenceItems(node *yaml.Node) []*yaml.Node { + if node == nil || node.Kind != yaml.SequenceNode { + return nil + } + return node.Content +} + +func sequenceItemsByName(node *yaml.Node) map[string]*yaml.Node { + items := make(map[string]*yaml.Node) + for _, item := range sequenceItems(node) { + if name := itemName(item); name != "" { + items[name] = item + } + } + return items +} + +func itemName(item *yaml.Node) string { + if name := mappingValueNode(item, "name"); name != nil && name.Kind == yaml.ScalarNode { + return name.Value + } + return "" +} diff --git a/dashboard/backend/handlers/deploy_policy_preservation_test.go b/dashboard/backend/handlers/deploy_policy_preservation_test.go new file mode 100644 index 00000000000..54742ce1a78 --- /dev/null +++ b/dashboard/backend/handlers/deploy_policy_preservation_test.go @@ -0,0 +1,199 @@ +package handlers + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "testing" + + "gopkg.in/yaml.v3" + + routerconfig "github.com/vllm-project/semantic-router/src/semantic-router/pkg/config" + "github.com/vllm-project/semantic-router/src/semantic-router/pkg/dsl" +) + +type adaptationsRouting struct { + Decisions []struct { + Name string `yaml:"name"` + Adaptations routerconfig.DecisionAdaptationsConfig `yaml:"adaptations"` + } `yaml:"decisions"` +} + +// decisionAdaptations maps each decision, as name or recipe/name, to its adaptations. +func decisionAdaptations(t *testing.T, raw []byte) map[string]routerconfig.DecisionAdaptationsConfig { + t.Helper() + var doc struct { + Routing adaptationsRouting `yaml:"routing"` + Recipes []struct { + Name string `yaml:"name"` + Routing adaptationsRouting `yaml:"routing"` + } `yaml:"recipes"` + } + if err := yaml.Unmarshal(raw, &doc); err != nil { + t.Fatalf("decode decisions: %v", err) + } + found := map[string]routerconfig.DecisionAdaptationsConfig{} + for _, decision := range doc.Routing.Decisions { + found[decision.Name] = decision.Adaptations + } + for _, recipe := range doc.Recipes { + for _, decision := range recipe.Routing.Decisions { + found[recipe.Name+"/"+decision.Name] = decision.Adaptations + } + } + return found +} + +func assertDecisionAdaptations(t *testing.T, merged []byte, want map[string]routerconfig.DecisionAdaptationsConfig) { + t.Helper() + got := decisionAdaptations(t, merged) + var changed []string + for name := range got { + if _, known := want[name]; !known { + changed = append(changed, name) + } + } + for name, value := range want { + if actual, ok := got[name]; !ok || !reflect.DeepEqual(actual, value) { + changed = append(changed, name) + } + } + if len(changed) > 0 { + sort.Strings(changed) + t.Fatalf("adaptations differ after deploy for decisions %v", changed) + } +} + +func TestMergeDeployPayloadKeepsBaseDecisionAdaptations(t *testing.T) { + base := `routing: + decisions: + - name: hard_policy + priority: 10 + adaptations: + mode: bypass + - name: plain + priority: 5 +recipes: + - name: alpha + routing: + decisions: + - name: shared + priority: 1 + adaptations: {mode: observe} + - name: beta + routing: + decisions: + - name: shared + priority: 1 + adaptations: + adaptation: {candidate_set: decision} +` + // A compiled DSL fragment never carries adaptations. + fragment := `routing: + decisions: + - name: hard_policy + priority: 20 + - name: plain + priority: 5 + - name: added + priority: 1 +recipes: + - name: alpha + routing: + decisions: + - name: shared + priority: 2 + - name: beta + routing: + decisions: + - name: shared + priority: 1 + adaptations: {mode: apply} + - name: gamma + routing: + decisions: + - name: shared + priority: 1 +` + want := map[string]routerconfig.DecisionAdaptationsConfig{ + "hard_policy": {Mode: routerconfig.DecisionAdaptationModeBypass}, + "plain": {}, + "added": {}, + "alpha/shared": {Mode: routerconfig.DecisionAdaptationModeObserve}, + "beta/shared": {Mode: routerconfig.DecisionAdaptationModeApply}, + "gamma/shared": {}, + } + + for _, mode := range []DeployMode{DeployModeReplace, DeployModeMerge} { + t.Run(string(mode), func(t *testing.T) { + merged, err := mergeDeployPayload([]byte(base), DeployRequest{YAML: fragment, Mode: mode}) + if err != nil { + t.Fatalf("mergeDeployPayload error: %v", err) + } + assertDecisionAdaptations(t, merged, want) + }) + } +} + +// The Builder imports a config, decompiles it to DSL, compiles it back, and +// deploys the compiled fragment in replace mode. +func TestMergeDeployPayloadReplaceKeepsMaintainedDecisionAdaptations(t *testing.T) { + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + for _, relative := range []string{ + "config/config.yaml", + "config/recipes/agent/config.yaml", + "config/recipes/built-in/latest/mom-v1/config.yaml", + "config/recipes/built-in/v0.4/mom-v1/config.yaml", + } { + t.Run(relative, func(t *testing.T) { + original, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(relative))) + if err != nil { + t.Fatalf("read config: %v", err) + } + want := decisionAdaptations(t, original) + if !hasDecisionAdaptations(want) { + t.Fatalf("%s has no decision adaptations to check", relative) + } + + cfg, err := routerconfig.ParseYAMLBytes(original) + if err != nil { + t.Fatalf("ParseYAMLBytes error: %v", err) + } + text, err := dsl.Decompile(cfg) + if err != nil { + t.Fatalf("Decompile error: %v", err) + } + compiled, errs := dsl.Compile(text) + if len(errs) > 0 { + t.Fatalf("Compile errors: %v", errs) + } + fragment, err := dsl.EmitRoutingYAMLFromConfig(compiled) + if err != nil { + t.Fatalf("EmitRoutingYAMLFromConfig error: %v", err) + } + + merged, err := mergeDeployPayload(original, DeployRequest{ + YAML: string(fragment), + BaseYAML: string(original), + Mode: DeployModeReplace, + }) + if err != nil { + t.Fatalf("mergeDeployPayload error: %v", err) + } + assertDecisionAdaptations(t, merged, want) + }) + } +} + +func hasDecisionAdaptations(adaptations map[string]routerconfig.DecisionAdaptationsConfig) bool { + for _, value := range adaptations { + if !reflect.DeepEqual(value, routerconfig.DecisionAdaptationsConfig{}) { + return true + } + } + return false +} From ecb9bbe3ce1d25ebf8b6d100efc172cc83c92810 Mon Sep 17 00:00:00 2001 From: Wilson Wu Date: Sat, 10 Oct 2026 02:53:18 +0800 Subject: [PATCH 2/2] Keep the v0.4 release snapshot out of the deploy round-trip test Since #4734 removed routing.data_policy from the schema, the current parser rejects the immutable v0.4 mom-v1 snapshot before the test reaches any adaptations check. Round-trip only the maintained configs: config/config.yaml, the agent recipe and the latest built-in mom-v1, which carries the same 16 decision adaptations as the snapshot. Signed-off-by: Wilson Wu --- dashboard/backend/handlers/deploy_policy_preservation_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/backend/handlers/deploy_policy_preservation_test.go b/dashboard/backend/handlers/deploy_policy_preservation_test.go index 54742ce1a78..346151ee504 100644 --- a/dashboard/backend/handlers/deploy_policy_preservation_test.go +++ b/dashboard/backend/handlers/deploy_policy_preservation_test.go @@ -143,11 +143,11 @@ func TestMergeDeployPayloadReplaceKeepsMaintainedDecisionAdaptations(t *testing. if err != nil { t.Fatalf("resolve repo root: %v", err) } + // Immutable release snapshots such as built-in/v0.4 may not parse with the current schema. for _, relative := range []string{ "config/config.yaml", "config/recipes/agent/config.yaml", "config/recipes/built-in/latest/mom-v1/config.yaml", - "config/recipes/built-in/v0.4/mom-v1/config.yaml", } { t.Run(relative, func(t *testing.T) { original, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(relative)))