package evolve import ( "context" "errors" "math" "reflect" "strings" "testing" ) // buildCandidate returns a Candidate whose Payload is the provided map, // keeping tests short. func buildCandidate(id string, payload map[string]float64) Candidate { return Candidate{ID: id, Payload: payload} } // costFeature reads Payload["cost"] defaulting to 0. func costFeature(c Candidate) float64 { m, _ := c.Payload.(map[string]float64) return m["cost"] } func latencyFeature(c Candidate) float64 { m, _ := c.Payload.(map[string]float64) return m["latency"] } // TestWeighted_HappyPath: fitness = cost*0.4 + latency*0.6 func TestWeighted_HappyPath(t *testing.T) { e, err := NewWeightedEvaluator( WithFeature("cost", costFeature), WithFeature("latency", latencyFeature), WithWeights(map[string]float64{"cost": 0.4, "latency": 0.6}), ) if err != nil { t.Fatalf("build: %v", err) } c := buildCandidate("c1", map[string]float64{"cost": 10, "latency": 5}) fit, bd, err := e.Score(context.Background(), c) if err != nil { t.Fatalf("Score: %v", err) } wantFit := 10*0.4 + 5*0.6 if math.Abs(fit-wantFit) > 1e-9 { t.Errorf("fit: want %v got %v", wantFit, fit) } if bd["cost"] != 10 || bd["latency"] != 5 { t.Errorf("breakdown raw values expected, got %+v", bd) } } // TestWeighted_BreakdownIsRawNotWeighted guards the contract that breakdown // reports raw feature values, not weighted contributions. func TestWeighted_BreakdownIsRawNotWeighted(t *testing.T) { e, _ := NewWeightedEvaluator( WithFeature("x", func(Candidate) float64 { return 8 }), WithWeight("x", 0.25), ) _, bd, _ := e.Score(context.Background(), Candidate{}) if bd["x"] != 8 { t.Errorf("breakdown must be raw 8, got %v (likely 8*0.25=2 bug)", bd["x"]) } } func TestWeighted_NegativeWeightAllowed(t *testing.T) { // Cost should penalise: weight -1 means "lower cost = higher fitness". e, err := NewWeightedEvaluator( WithFeature("cost", costFeature), WithWeight("cost", -1.0), ) if err != nil { t.Fatalf("build: %v", err) } fit, _, _ := e.Score(context.Background(), buildCandidate("c", map[string]float64{"cost": 12})) if fit != -12 { t.Errorf("negative weight: want -12, got %v", fit) } } func TestWeighted_NormalizationClipsToUnit(t *testing.T) { e, _ := NewWeightedEvaluator( WithFeature("a", func(Candidate) float64 { return 5.0 }), // will clip to 1 WithFeature("b", func(Candidate) float64 { return -0.3 }), // will clip to 0 WithWeights(map[string]float64{"a": 1, "b": 1}), WithNormalization(true), ) fit, bd, _ := e.Score(context.Background(), Candidate{}) if bd["a"] != 1 || bd["b"] != 0 { t.Errorf("clipped breakdown: got %+v", bd) } if fit != 1 { t.Errorf("normalized fitness: want 1, got %v", fit) } } func TestWeighted_NoFeaturesRejected(t *testing.T) { _, err := NewWeightedEvaluator() if err == nil { t.Fatal("want error for no features") } if !strings.Contains(err.Error(), "at least one feature") { t.Errorf("err message: %v", err) } } func TestWeighted_FeatureWithoutWeightRejected(t *testing.T) { _, err := NewWeightedEvaluator( WithFeature("a", func(Candidate) float64 { return 0 }), WithFeature("b", func(Candidate) float64 { return 0 }), WithWeight("a", 1.0), // "b" has no weight ) if err == nil { t.Fatal("want error for feature without weight") } if !strings.Contains(err.Error(), `feature "b" has no weight`) { t.Errorf("err should name missing weight for b: %v", err) } } func TestWeighted_OrphanWeightRejected(t *testing.T) { _, err := NewWeightedEvaluator( WithFeature("a", func(Candidate) float64 { return 0 }), WithWeights(map[string]float64{"a": 1, "ghost": 2}), ) if err == nil { t.Fatal("want error for orphan weight") } if !strings.Contains(err.Error(), `weight "ghost" has no matching feature`) { t.Errorf("err should name orphan: %v", err) } } func TestWeighted_DuplicateFeatureRejected(t *testing.T) { _, err := NewWeightedEvaluator( WithFeature("a", func(Candidate) float64 { return 1 }), WithFeature("a", func(Candidate) float64 { return 2 }), WithWeight("a", 1), ) if err == nil { t.Fatal("want error for duplicate feature") } } func TestWeighted_EmptyNamesRejected(t *testing.T) { _, err := NewWeightedEvaluator( WithFeature("", func(Candidate) float64 { return 0 }), WithWeight("", 1), ) if err == nil { t.Fatal("want error for empty names") } } func TestWeighted_NilFnRejected(t *testing.T) { _, err := NewWeightedEvaluator( WithFeature("a", nil), WithWeight("a", 1), ) if err == nil { t.Fatal("want error for nil fn") } } func TestWeighted_NaNInfWeightRejected(t *testing.T) { _, err := NewWeightedEvaluator( WithFeature("a", func(Candidate) float64 { return 0 }), WithWeight("a", math.NaN()), ) if err == nil { t.Fatal("NaN weight: want error") } _, err = NewWeightedEvaluator( WithFeature("a", func(Candidate) float64 { return 0 }), WithWeights(map[string]float64{"a": math.Inf(1)}), ) if err == nil { t.Fatal("Inf weight: want error") } } func TestWeighted_CtxCanceled(t *testing.T) { e, _ := NewWeightedEvaluator( WithFeature("a", func(Candidate) float64 { return 1 }), WithWeight("a", 1), ) ctx, cancel := context.WithCancel(context.Background()) cancel() _, _, err := e.Score(ctx, Candidate{}) if !errors.Is(err, context.Canceled) { t.Errorf("want context.Canceled, got %v", err) } } func TestWeighted_ScoreIsDeterministicIdempotent(t *testing.T) { e, _ := NewWeightedEvaluator( WithFeature("a", func(Candidate) float64 { return 3 }), WithFeature("b", func(Candidate) float64 { return 7 }), WithFeature("c", func(Candidate) float64 { return 11 }), WithWeights(map[string]float64{"a": 0.1, "b": 0.2, "c": 0.3}), ) first, _, _ := e.Score(context.Background(), Candidate{}) for i := 0; i < 20; i++ { got, _, _ := e.Score(context.Background(), Candidate{}) if got != first { t.Fatalf("non-deterministic Score at iter %d: %v vs %v", i, got, first) } } } func TestWeighted_FeaturesAndWeightsGetters(t *testing.T) { e, _ := NewWeightedEvaluator( WithFeature("a", func(Candidate) float64 { return 0 }), WithFeature("b", func(Candidate) float64 { return 0 }), WithWeights(map[string]float64{"a": 1, "b": 2}), ) names := e.Features() if !reflect.DeepEqual(names, []string{"a", "b"}) { t.Errorf("Features sorted: %v", names) } ws := e.Weights() if ws["a"] != 1 || ws["b"] != 2 { t.Errorf("Weights: %v", ws) } // Mutating the returned map must not change the evaluator. ws["a"] = 999 reread := e.Weights() if reread["a"] != 1 { t.Errorf("Weights() leaked mutable map: %v", reread) } } // ============================================================================ // FuncEvaluator // ============================================================================ func TestFunc_Basic(t *testing.T) { e, err := NewFuncEvaluator(func(_ context.Context, c Candidate) (float64, map[string]float64, error) { return 42, map[string]float64{"only": 42}, nil }) if err != nil { t.Fatal(err) } fit, bd, err := e.Score(context.Background(), Candidate{}) if err != nil || fit != 42 || bd["only"] != 42 { t.Errorf("Func basic: fit=%v bd=%v err=%v", fit, bd, err) } } func TestFunc_ErrorPropagates(t *testing.T) { boom := errors.New("boom") e, _ := NewFuncEvaluator(func(context.Context, Candidate) (float64, map[string]float64, error) { return 0, nil, boom }) _, _, err := e.Score(context.Background(), Candidate{}) if !errors.Is(err, boom) { t.Errorf("Func error propagation: %v", err) } } func TestFunc_NilFnRejected(t *testing.T) { if _, err := NewFuncEvaluator(nil); err == nil { t.Fatal("nil fn: want error") } } func TestFunc_CtxCanceledShortCircuits(t *testing.T) { called := false e, _ := NewFuncEvaluator(func(context.Context, Candidate) (float64, map[string]float64, error) { called = true return 0, nil, nil }) ctx, cancel := context.WithCancel(context.Background()) cancel() _, _, err := e.Score(ctx, Candidate{}) if !errors.Is(err, context.Canceled) { t.Errorf("ctx canceled: want context.Canceled, got %v", err) } if called { t.Errorf("wrapped fn must not be called on canceled ctx") } } func TestFunc_ImplementsEvaluatorInterface(t *testing.T) { e, _ := NewFuncEvaluator(func(context.Context, Candidate) (float64, map[string]float64, error) { return 1, nil, nil }) var _ Evaluator = e } func TestWeighted_ImplementsEvaluatorInterface(t *testing.T) { e, _ := NewWeightedEvaluator( WithFeature("a", func(Candidate) float64 { return 0 }), WithWeight("a", 1), ) var _ Evaluator = e }