diff --git a/internal/api/exchange_lookup.go b/internal/api/exchange_lookup.go index 11e5eb276..367180e81 100644 --- a/internal/api/exchange_lookup.go +++ b/internal/api/exchange_lookup.go @@ -100,14 +100,26 @@ func recommendationToOffering(rec config.RecommendationRecord, currencyCode stri if rec.Term > 0 { termSeconds = int64(rec.Term) * secondsPerYear } - return exchange.OfferingOption{ + opt := exchange.OfferingOption{ InstanceType: rec.ResourceType, OfferingID: rec.ID, EffectiveMonthlyCost: monthly, NormalizationFactor: exchange.NormalizationFactorForSize(size), CurrencyCode: currencyCode, TermSeconds: termSeconds, + RecommendationCount: rec.Count, } + // Propagate absolute savings from the CE recommendation into the + // OfferingOption so compositeScore can derive a confidence signal. + // Savings is always non-negative by convention; a zero value is + // indistinguishable from "not populated" only when combined with a + // zero Count, so we propagate whenever Savings > 0 to avoid the + // ambiguity. + if rec.Savings > 0 { + s := rec.Savings + opt.SavingsAbs = &s + } + return opt } // secondsPerYear is the AWS-canonical RI duration constant for a 1-year diff --git a/pkg/exchange/reshape.go b/pkg/exchange/reshape.go index 211e2656d..fb79830ca 100644 --- a/pkg/exchange/reshape.go +++ b/pkg/exchange/reshape.go @@ -81,6 +81,19 @@ type OfferingOption struct { // on either side falls back to "skip the term guard" for callers // or fixtures that don't populate it. TermSeconds int64 `json:"term_seconds,omitempty"` + // SavingsAbs is the absolute monthly savings (in CurrencyCode) that + // AWS Cost Explorer reported for this recommendation. Used by + // compositeScore to estimate savings % and derive a confidence + // signal. Zero means "not supplied" — the score component is omitted + // rather than coerced to 0 (which would falsely rank this offering + // last on the savings axis). + SavingsAbs *float64 `json:"savings_abs,omitempty"` + // RecommendationCount is the number of instances AWS Cost Explorer + // included in the recommendation. Used together with SavingsAbs to + // derive a confidence signal (large fleet + large savings = high + // confidence). Zero means "not supplied"; the confidence signal is + // skipped rather than treated as a 0-instance fleet. + RecommendationCount int `json:"recommendation_count,omitempty"` } // PurchaseRecLookup is the signature of the closure that resolves a @@ -149,6 +162,200 @@ func passesDollarUnitsCheck(srcNF, srcMonthlyCost float64, srcCurrency string, t return target.NormalizationFactor*target.EffectiveMonthlyCost >= srcNF*srcMonthlyCost } +// Composite score weights. Higher score = better alternative. The price term +// is dominant; the remaining bonuses/penalties nudge the ranking but cannot +// flip alternatives whose effective cost differs by more than a few percent. +// +// - scoreWeightCost: the cost term contributes the largest share +// - scoreWeightFamilyGen: same-generation-jump bonus (m5->m6i beats m5->r5) +// - scoreWeightArch: cross-architecture penalty (Intel->ARM requires AMI validation) +// - scoreWeightConfidence: high/medium/low CE confidence bonus +// - scoreWeightNFProximity: normalized-capacity proximity bonus (1.0 = exact match) +const ( + scoreWeightCost = 0.60 + scoreWeightFamilyGen = 0.15 + scoreWeightArch = 0.10 + scoreWeightConfidence = 0.10 + scoreWeightNFProximity = 0.05 +) + +// compositeScore returns a score in the range [0, 1] for an offering +// relative to the source RI. Higher is better. The five components are: +// +// 1. Cost (dominant, 60%): inverted effective monthly cost relative to the +// source RI so that cheaper offerings score higher. When source pricing +// is absent the component is replaced with a neutral 0.5 mid-point rather +// than 0 (which would unfairly penalise offerings for a data gap in src). +// +// 2. Family-generation fit (15%): a same-prefix generation-jump (m5->m6i) +// scores full marks; a cross-prefix alternative (m5->r5) scores zero. +// +// 3. Architecture match (10%): same architecture (both x86 or both ARM) +// avoids an AMI-compatibility concern; a cross-arch alternative pays a +// penalty. When the architecture is ambiguous (unknown family suffix) +// no penalty is applied. +// +// 4. CE confidence (10%): derived from SavingsAbs + RecommendationCount +// using the same heuristic as confidenceBucketFor in the API layer. +// High->1.0, medium->0.5, low->0.0. Zero-value signals are treated +// as absent (neutral 0.5) rather than "low" to avoid penalising +// offerings that predate the field. +// +// 5. NF proximity (5%): how close the alternative's normalization capacity +// is to the source RI's. score = 1 - min(|ratio-1|, 1). Exact match +// (ratio = 1) scores 1.0; double or half capacity scores 0.0. +// Zero/absent NF is treated as neutral 0.5. +func compositeScore(off OfferingOption, src RIInfo) float64 { + return scoreWeightCost*costComponent(off, src) + + scoreWeightFamilyGen*familyGenComponent(off, src) + + scoreWeightArch*archComponent(off, src) + + scoreWeightConfidence*confidenceComponent(off) + + scoreWeightNFProximity*nfProximityComponent(off, src) +} + +// costComponent returns a [0,1] cost score for the offering. +// When src pricing is absent (MonthlyCost == 0) returns 0.5 (neutral) so +// that a missing field does not falsely penalise or reward the offering. +func costComponent(off OfferingOption, src RIInfo) float64 { + if src.MonthlyCost <= 0 || off.EffectiveMonthlyCost <= 0 { + return 0.5 + } + // Cheaper than source scores close to 1.0; equal scores ~0.5; + // more expensive trends toward 0.0. Clamp to [0,1]. + ratio := src.MonthlyCost / off.EffectiveMonthlyCost + // ratio > 1 means offering is cheaper (good); ratio < 1 means more expensive. + // Map via: score = ratio / (1 + ratio) which is monotone on (0,+inf), + // passes through 0.5 at ratio=1, and is bounded in [0,1]. + return ratio / (1.0 + ratio) +} + +// familyGenComponent returns 1.0 when the offering is a same-family-prefix +// generation jump (e.g. m5->m6i), 0.0 otherwise. The prefix is the leading +// letters of the family name before the generation digit. +func familyGenComponent(off OfferingOption, src RIInfo) float64 { + srcFamily, _ := parseInstanceType(src.InstanceType) + offFamily, _ := parseInstanceType(off.InstanceType) + if srcFamily == "" || offFamily == "" { + return 0.0 + } + if familyPrefix(srcFamily) == familyPrefix(offFamily) { + return 1.0 + } + return 0.0 +} + +// familyPrefix returns the leading letter(s) from a family name, stripping +// the generation number and suffix (e.g. "m6i" -> "m", "r5" -> "r", +// "c6gn" -> "c"). Returns the whole string if no digit is found. +func familyPrefix(family string) string { + for i, r := range family { + if r >= '0' && r <= '9' { + return family[:i] + } + } + return family +} + +// archComponent returns 1.0 when source and offering share the same +// processor architecture, 0.0 for a cross-arch pairing. When either +// family is ambiguous (unknown suffix) returns 0.5 (neutral, no penalty). +// +// Architecture is inferred from the family suffix: +// - families ending in "g" or "gd" or "gn" use ARM (AWS Graviton) +// - all other known suffixes (blank, "i", "n", "d", "a", "id", etc.) use x86 +func archComponent(off OfferingOption, src RIInfo) float64 { + srcFamily, _ := parseInstanceType(src.InstanceType) + offFamily, _ := parseInstanceType(off.InstanceType) + if srcFamily == "" || offFamily == "" { + return 0.5 + } + srcIsARM := isARMFamily(srcFamily) + offIsARM := isARMFamily(offFamily) + if srcIsARM == offIsARM { + return 1.0 + } + return 0.0 +} + +// isARMFamily returns true when the EC2 family name indicates a Graviton +// (ARM) processor. Graviton families use exactly one of the suffixes "g", +// "gd", or "gn" after the generation number. Detection: +// 1. Exclude the "im" and "is" prefixes (Intel Dense Storage/Network +// families such as "im4gn" and "is4gen") whose names contain "g" but +// are x86-only -- they would otherwise be false-positives. +// 2. Strip the leading prefix letters and the generation digits, then +// require an EXACT match against the known Graviton suffix set {"g", +// "gd", "gn"} -- not a prefix-contains check. +// +// Examples: "m6g"->ARM, "m6gd"->ARM, "m6gn"->ARM, "m7g"->ARM, +// "hpc7g"->ARM, "t4g"->ARM, "m6i"->x86, "c5n"->x86, "r6a"->x86 (AMD), +// "g4dn"->x86 (NVIDIA GPU), "is4gen"->x86, "im4gn"->x86. +func isARMFamily(family string) bool { + // "im" and "is" are Intel Dense Storage/Network prefixes; they are x86 + // even though their names contain "g" characters. + prefix := familyPrefix(family) + if prefix == "im" || prefix == "is" { + return false + } + // Strip the leading prefix letters to get "generation+suffix". + genSuffix := strings.TrimLeft(family, "abcdefghijklmnopqrstuvwxyz") + // Strip leading digits (the generation number). + suffix := strings.TrimLeft(genSuffix, "0123456789") + // Only these exact suffixes indicate a Graviton (ARM) processor. + return suffix == "g" || suffix == "gd" || suffix == "gn" +} + +// confidenceComponent maps SavingsAbs + RecommendationCount to a [0,1] +// confidence score using the same thresholds as the API layer: +// +// high ($200+ savings, 3+ instances) -> 1.0 +// medium ($50+ savings) -> 0.5 +// low (otherwise) -> 0.0 +// +// When SavingsAbs is nil (not supplied) or RecommendationCount is zero +// (missing) the component returns 0.5 (neutral) so that an absent field +// doesn't tilt the ranking. +func confidenceComponent(off OfferingOption) float64 { + if off.SavingsAbs == nil { + return 0.5 + } + if off.RecommendationCount == 0 { + return 0.5 + } + savings := *off.SavingsAbs + count := off.RecommendationCount + switch { + case savings >= 200 && count >= 3: + return 1.0 + case savings >= 50: + return 0.5 + default: + return 0.0 + } +} + +// nfProximityComponent returns a [0,1] score based on how close the +// offering's total normalized capacity is to the source RI. An exact match +// (ratio = 1.0) scores 1.0; a 2x or 0.5x difference scores 0.0. When +// either NF is zero/absent (src.NormalizationFactor == 0 or +// off.NormalizationFactor == 0) the component returns 0.5 (neutral). +func nfProximityComponent(off OfferingOption, src RIInfo) float64 { + srcNF := src.NormalizationFactor + if srcNF <= 0 { + _, size := parseInstanceType(src.InstanceType) + srcNF = normalizationFactors[size] + } + if srcNF <= 0 || off.NormalizationFactor <= 0 { + return 0.5 + } + ratio := off.NormalizationFactor / srcNF + deviation := math.Abs(ratio - 1.0) + if deviation >= 1.0 { + return 0.0 + } + return 1.0 - deviation +} + // normalizationFactors maps EC2 instance sizes to their AWS normalization factors. // See: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ri-modification-instancemove.html var normalizationFactors = map[string]float64{ @@ -361,12 +568,13 @@ func AnalyzeReshapingWithRecs( // fillAlternativesFromRecs is the per-rec body of // AnalyzeReshapingWithRecs: for each rec it filters the offerings list // to cross-family options that match the source RI's term and pass the -// dollar-units gate, then sorts them ascending by EffectiveMonthlyCost -// so the cheapest lands first in the UI list. The source family and -// the primary target are excluded so the alternatives slice is -// meaningfully different from the primary suggestion. When source -// pricing or the source/offering term is missing the relevant gate is -// skipped for that rec (NF/MonthlyCost==0 / TermSeconds==0 cases). +// dollar-units gate, then ranks them by compositeScore (descending) so +// the best overall alternative lands first. Tie-break: ascending +// EffectiveMonthlyCost. The source family and the primary target are +// excluded so the alternatives slice is meaningfully different from the +// primary suggestion. When source pricing or the source/offering term is +// missing the relevant gate is skipped for that rec (NF/MonthlyCost==0 +// / TermSeconds==0 cases). func fillAlternativesFromRecs(recs []ReshapeRecommendation, offerings []OfferingOption, risByID map[string]RIInfo) { for i := range recs { src, hasSrc := risByID[recs[i].SourceRIID] @@ -379,7 +587,18 @@ func fillAlternativesFromRecs(recs []ReshapeRecommendation, offerings []Offering filled = append(filled, off) } sort.Slice(filled, func(a, b int) bool { - return filled[a].EffectiveMonthlyCost < filled[b].EffectiveMonthlyCost + // Higher composite score = better alternative. Tie-break by + // ascending EffectiveMonthlyCost, then by InstanceType + // lexicographically so the order is fully deterministic. + sa := compositeScore(filled[a], src) + sb := compositeScore(filled[b], src) + if sa != sb { + return sa > sb + } + if filled[a].EffectiveMonthlyCost != filled[b].EffectiveMonthlyCost { + return filled[a].EffectiveMonthlyCost < filled[b].EffectiveMonthlyCost + } + return filled[a].InstanceType < filled[b].InstanceType }) recs[i].AlternativeTargets = filled } diff --git a/pkg/exchange/reshape_crossfamily_test.go b/pkg/exchange/reshape_crossfamily_test.go index 4b5439494..7d4eb7f60 100644 --- a/pkg/exchange/reshape_crossfamily_test.go +++ b/pkg/exchange/reshape_crossfamily_test.go @@ -446,3 +446,297 @@ func TestPassesDollarUnitsCheck(t *testing.T) { }) } } + +// --- composite score ranking tests --- + +// floatPtr is a test helper that returns a pointer to a float64 value. +func floatPtr(v float64) *float64 { return &v } + +// TestCompositeScore_SameGenOutranksTermMismatch asserts that a +// near-perfect alternative (same family prefix, exact NF, high confidence) +// outranks an alternative with identical savings but no family match and +// no confidence signal. +func TestCompositeScore_SameGenOutranksTermMismatch(t *testing.T) { + t.Parallel() + src := RIInfo{ + InstanceType: "m5.xlarge", + NormalizationFactor: 8, + MonthlyCost: 80, + } + // m6i.xlarge: same "m" prefix (gen jump bonus), exact NF, high confidence. + nearPerfect := OfferingOption{ + InstanceType: "m6i.xlarge", + EffectiveMonthlyCost: 85, // slightly more expensive + NormalizationFactor: 8, + SavingsAbs: floatPtr(220), + RecommendationCount: 5, + } + // r5.xlarge: different prefix (no family gen bonus), same NF, no confidence. + crossFamily := OfferingOption{ + InstanceType: "r5.xlarge", + EffectiveMonthlyCost: 80, // same cost as source -- better raw price + NormalizationFactor: 8, + } + scoreNearPerfect := compositeScore(nearPerfect, src) + scoreCrossFamily := compositeScore(crossFamily, src) + assert.Greater(t, scoreNearPerfect, scoreCrossFamily, + "same-gen m6i should outrank cross-family r5 despite being slightly more expensive") +} + +// TestCompositeScore_SameArchOutranksCrossArch asserts that a same- +// architecture alternative ranks higher than a cross-arch offering when +// all other signals (family prefix, cost, NF, confidence) are equal. +// Both offerings share the same "c" family prefix as the source so the +// family-gen component is identical and only the arch component differs. +func TestCompositeScore_SameArchOutranksCrossArch(t *testing.T) { + t.Parallel() + src := RIInfo{ + InstanceType: "c5.xlarge", // Intel x86, prefix "c" + NormalizationFactor: 8, + MonthlyCost: 90, + } + // c6i: same "c" prefix (family gen bonus), Intel x86 (same arch). + sameArch := OfferingOption{ + InstanceType: "c6i.xlarge", + EffectiveMonthlyCost: 90, + NormalizationFactor: 8, + } + // c6g: same "c" prefix (family gen bonus), Graviton ARM (cross arch). + crossArch := OfferingOption{ + InstanceType: "c6g.xlarge", + EffectiveMonthlyCost: 90, + NormalizationFactor: 8, + } + assert.Greater(t, compositeScore(sameArch, src), compositeScore(crossArch, src), + "x86->x86 should outrank x86->ARM when family-gen bonus is equal for both") +} + +// TestCompositeScore_HighConfidenceOutranksLow asserts that a high- +// confidence recommendation (large savings + large fleet) ranks higher +// than a low-confidence one at otherwise equal signals. +func TestCompositeScore_HighConfidenceOutranksLow(t *testing.T) { + t.Parallel() + src := RIInfo{ + InstanceType: "c5.xlarge", + NormalizationFactor: 8, + MonthlyCost: 70, + } + highConf := OfferingOption{ + InstanceType: "r5.xlarge", + EffectiveMonthlyCost: 70, + NormalizationFactor: 8, + SavingsAbs: floatPtr(250), + RecommendationCount: 4, + } + lowConf := OfferingOption{ + InstanceType: "r5.xlarge", + EffectiveMonthlyCost: 70, + NormalizationFactor: 8, + SavingsAbs: floatPtr(10), + RecommendationCount: 1, + } + assert.Greater(t, compositeScore(highConf, src), compositeScore(lowConf, src), + "high-confidence CE rec should outrank low-confidence at equal cost") +} + +// TestCompositeScore_AbsentSavingsIsNeutral asserts that an offering with +// nil SavingsAbs does not get penalised relative to a low-confidence one. +// Per the nullable-not-zero rule, absent data must not be coerced to 0. +func TestCompositeScore_AbsentSavingsIsNeutral(t *testing.T) { + t.Parallel() + src := RIInfo{ + InstanceType: "c5.xlarge", + NormalizationFactor: 8, + MonthlyCost: 70, + } + absentSavings := OfferingOption{ + InstanceType: "r6i.xlarge", + EffectiveMonthlyCost: 70, + NormalizationFactor: 8, + SavingsAbs: nil, // not supplied + } + lowConf := OfferingOption{ + InstanceType: "r6i.xlarge", + EffectiveMonthlyCost: 70, + NormalizationFactor: 8, + SavingsAbs: floatPtr(5), // explicitly low confidence + } + // absent must score >= low confidence (neutral 0.5 vs. 0.0) + assert.GreaterOrEqual(t, compositeScore(absentSavings, src), compositeScore(lowConf, src), + "nil SavingsAbs should be treated as neutral (0.5), not as low confidence (0.0)") +} + +// TestCompositeScore_FamilyPrefixDetection confirms familyPrefix strips +// generation digits and suffixes correctly for both x86 and ARM families. +func TestCompositeScore_FamilyPrefixDetection(t *testing.T) { + t.Parallel() + cases := []struct { + family string + want string + }{ + {"m5", "m"}, + {"m6i", "m"}, + {"m6g", "m"}, + {"c6gn", "c"}, + {"r5", "r"}, + {"hpc7g", "hpc"}, + {"t4g", "t"}, + } + for _, c := range cases { + t.Run(c.family, func(t *testing.T) { + t.Parallel() + assert.Equal(t, c.want, familyPrefix(c.family)) + }) + } +} + +// TestCompositeScore_ARMDetection confirms isARMFamily correctly classifies +// Graviton (ARM) vs. non-Graviton (x86/AMD) families. +func TestCompositeScore_ARMDetection(t *testing.T) { + t.Parallel() + cases := []struct { + family string + arm bool + }{ + // Graviton (ARM) families. + {"m6g", true}, + {"m6gd", true}, + {"m6gn", true}, + {"m7g", true}, + {"hpc7g", true}, + {"t4g", true}, + // x86 / AMD families -- must NOT be classified as ARM. + {"m6i", false}, + {"c5n", false}, + {"r6a", false}, // AMD, not ARM + {"m5", false}, + {"r5d", false}, + // Intel Dense Storage/Network families whose names contain "g" + // but are x86-only and were previously false-positives. + {"is4gen", false}, // Intel Dense Storage Gen 4, x86 + {"im4gn", false}, // Intel Dense NVMe, x86 + // NVIDIA GPU families (x86) whose name starts with "g". + {"g4dn", false}, + {"g5", false}, + } + for _, c := range cases { + t.Run(c.family, func(t *testing.T) { + t.Parallel() + assert.Equal(t, c.arm, isARMFamily(c.family), + "isARMFamily(%q) should be %v", c.family, c.arm) + }) + } +} + +// TestFillAlternativesFromRecs_DeterministicOrder verifies that two offerings +// with identical composite scores AND identical EffectiveMonthlyCost are +// always ordered by InstanceType lexicographically, ensuring a stable, +// reproducible result regardless of the input slice order. +func TestFillAlternativesFromRecs_DeterministicOrder(t *testing.T) { + t.Parallel() + + // Both offerings are x86, same-prefix "m" (-> family gen bonus = 1), + // same EMC, same savings -> identical composite scores. Without the + // InstanceType tie-break the output order would be non-deterministic. + savings := 60.0 + lookup := func(_ context.Context, _, _ string) ([]OfferingOption, error) { + // Deliberately provide offerings in reverse-lexical order so that + // the test fails if the tie-break is absent. + return []OfferingOption{ + { + InstanceType: "m6i.xlarge", OfferingID: "off-m6i", + EffectiveMonthlyCost: 10, NormalizationFactor: 8, CurrencyCode: "USD", + SavingsAbs: &savings, RecommendationCount: 2, + }, + { + InstanceType: "m6a.xlarge", OfferingID: "off-m6a", + EffectiveMonthlyCost: 10, NormalizationFactor: 8, CurrencyCode: "USD", + SavingsAbs: &savings, RecommendationCount: 2, + }, + }, nil + } + + recs := AnalyzeReshapingWithRecs( + context.Background(), + []RIInfo{{ + ID: "ri-1", InstanceType: "m5.xlarge", InstanceCount: 1, OfferingClass: "convertible", + NormalizationFactor: 8, MonthlyCost: 10, CurrencyCode: "USD", + }}, + []UtilizationInfo{{RIID: "ri-1", UtilizationPercent: 50}}, + 95, + "us-east-1", "USD", + lookup, + ) + require.Len(t, recs, 1) + alts := recs[0].AlternativeTargets + require.Len(t, alts, 2, "both cross-family alternatives must survive eligibility gates") + + // Lexicographic order: "m6a.xlarge" < "m6i.xlarge" + assert.Equal(t, "m6a.xlarge", alts[0].InstanceType, + "equal-score equal-EMC alternatives must be ordered lexicographically by InstanceType") + assert.Equal(t, "m6i.xlarge", alts[1].InstanceType, + "equal-score equal-EMC alternatives must be ordered lexicographically by InstanceType") +} + +// TestAnalyzeReshapingWithRecs_CompositeScoreOrdersAlternatives is a +// snapshot-style integration test: given three alternatives with distinct +// composite score profiles, the slice must arrive in composite-score +// (descending) order. +// +// Source: m5.xlarge, NF=8, MonthlyCost=10 (src_units = 80). +// All alternatives use NF=8 and EMC=10 (units = 80 >= 80, passes the +// dollar-units gate). Their composite ranking is determined entirely by +// the non-cost signals: +// +// - m6i.xlarge: same "m" prefix (family gen bonus=1), x86->x86 (arch=1), high confidence +// - m6g.xlarge: same "m" prefix (family gen bonus=1), x86->ARM (arch=0), medium confidence +// - c5.xlarge: cross prefix (family gen=0), x86->x86 (arch=1), low confidence +// +// Expected order: m6i > m6g > c5. +func TestAnalyzeReshapingWithRecs_CompositeScoreOrdersAlternatives(t *testing.T) { + t.Parallel() + + highSavings := 220.0 + medSavings := 60.0 + lowSavings := 15.0 + + lookup := func(_ context.Context, _, _ string) ([]OfferingOption, error) { + return []OfferingOption{ + { + InstanceType: "c5.xlarge", OfferingID: "off-c5", + EffectiveMonthlyCost: 10, NormalizationFactor: 8, CurrencyCode: "USD", + SavingsAbs: &lowSavings, RecommendationCount: 1, + }, + { + InstanceType: "m6g.xlarge", OfferingID: "off-m6g", + EffectiveMonthlyCost: 10, NormalizationFactor: 8, CurrencyCode: "USD", + SavingsAbs: &medSavings, RecommendationCount: 2, + }, + { + InstanceType: "m6i.xlarge", OfferingID: "off-m6i", + EffectiveMonthlyCost: 10, NormalizationFactor: 8, CurrencyCode: "USD", + SavingsAbs: &highSavings, RecommendationCount: 4, + }, + }, nil + } + + recs := AnalyzeReshapingWithRecs( + context.Background(), + []RIInfo{{ + ID: "ri-1", InstanceType: "m5.xlarge", InstanceCount: 1, OfferingClass: "convertible", + NormalizationFactor: 8, MonthlyCost: 10, CurrencyCode: "USD", + }}, + []UtilizationInfo{{RIID: "ri-1", UtilizationPercent: 50}}, + 95, + "us-east-1", "USD", + lookup, + ) + require.Len(t, recs, 1) + alts := recs[0].AlternativeTargets + require.Len(t, alts, 3, "all three cross-family alternatives must survive the eligibility gates") + + // Assert composite-score ordering: m6i > m6g > c5. + assert.Equal(t, "m6i.xlarge", alts[0].InstanceType, "m6i: same prefix + high confidence should rank first") + assert.Equal(t, "m6g.xlarge", alts[1].InstanceType, "m6g: same prefix but ARM penalty places it second") + assert.Equal(t, "c5.xlarge", alts[2].InstanceType, "c5: no family bonus + low confidence should rank last") +}