Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion internal/api/exchange_lookup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
233 changes: 226 additions & 7 deletions pkg/exchange/reshape.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Comment on lines +308 to +335

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle missing RecommendationCount as neutral in confidence scoring.

At Line 323, missing/zero RecommendationCount is coerced to 1, which can still produce a low-confidence penalty when count is actually absent. That conflicts with the neutral-missing contract documented at Lines 94-95.

Proposed fix
 func confidenceComponent(off OfferingOption) float64 {
-	if off.SavingsAbs == nil {
+	if off.SavingsAbs == nil || off.RecommendationCount <= 0 {
 		return 0.5
 	}
 	savings := *off.SavingsAbs
 	count := off.RecommendationCount
-	if count < 1 {
-		count = 1
-	}
 	switch {
 	case savings >= 200 && count >= 3:
 		return 1.0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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) 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
}
savings := *off.SavingsAbs
count := off.RecommendationCount
if count < 1 {
count = 1
}
switch {
case savings >= 200 && count >= 3:
return 1.0
case savings >= 50:
return 0.5
default:
return 0.0
}
}
// 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) 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 || 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
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/exchange/reshape.go` around lines 308 - 334, The confidenceComponent
function currently coerces RecommendationCount == 0 to 1 which can incorrectly
penalize offers when the count is actually missing; change the logic in
confidenceComponent (which takes an OfferingOption) so that if
off.RecommendationCount == 0 you treat it as "missing" and return 0.5 (neutral)
instead of coercing to 1 — otherwise use the actual count for the existing
savings thresholds (savings >= 200 && count >= 3 -> 1.0, savings >= 50 -> 0.5,
else 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{
Expand Down Expand Up @@ -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]
Expand All @@ -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
}
Expand Down
Loading
Loading