@@ -377,13 +377,32 @@ type FamilyExpiryReport struct {
377377 // ExpiringNU is the normalized units of active RDS RIs ending inside the
378378 // window that were deducted from family coverage.
379379 ExpiringNU float64
380+ // ReplacementNU is the normalized units of visible active, non-expiring RDS
381+ // RIs that started inside the coverage lookback and so are not yet in the
382+ // lagged coverage, summed over families that have expiring RIs. It is only
383+ // non-zero when FamilyExpiryOptions.CoverageLookbackDays > 0.
384+ ReplacementNU float64
380385 // Unusable counts active RDS RIs expiring inside the window that could not
381386 // be deducted: an unparsable instance class (NU 0) or a family key with no
382387 // coverage family (e.g. an engine spelled differently from the coverage
383388 // report). Callers must surface this; it never means "no expiry".
384389 Unusable int
385390}
386391
392+ // FamilyExpiryOptions configures ApplyFamilyNUSizingRDSWithExpiryOptions.
393+ type FamilyExpiryOptions struct {
394+ // WindowDays is the rebuy window: active RIs ending within it are treated
395+ // as expiring. <= 0 disables the expiry adjustment.
396+ WindowDays int
397+ // CoverageLookbackDays is the lookback, in days, of the Cost Explorer
398+ // coverage average the caller passed in (the lookbackDays given to
399+ // GetRICoverageMap). Coverage is an average over that period, so an RI that
400+ // started inside it is not yet fully reflected. Visible active, non-expiring
401+ // RIs that started inside it are credited as already-bought replacements.
402+ // <= 0 credits none, which is exactly ApplyFamilyNUSizingRDSWithExpiry.
403+ CoverageLookbackDays int
404+ }
405+
387406// ApplyFamilyNUSizingRDSWithExpiry is ApplyFamilyNUSizingRDS with rebuy-window
388407// expiry folded into family coverage first, in normalized units. RDS RIs are
389408// size-flexible and Cost Explorer reports coverage under the size actually
@@ -400,32 +419,86 @@ type FamilyExpiryReport struct {
400419// actually expires.
401420// windowDays <= 0 or no commitments leaves coverage unchanged. A family
402421// with several size rows or linked-account rows is deducted once.
422+ //
423+ // This form ignores replacement RIs the account already bought for the
424+ // expiring ones; callers that know the coverage lookback should use
425+ // ApplyFamilyNUSizingRDSWithExpiryOptions.
403426func ApplyFamilyNUSizingRDSWithExpiry (
404427 recs []common.Recommendation ,
405428 coverage PoolCoverageMap ,
406429 targetPct float64 ,
407430 commitments []common.Commitment ,
408431 windowDays int ,
432+ ) (sizedRDS , nonRDS []common.Recommendation , drops FamilyDropCounts , expiry FamilyExpiryReport ) {
433+ return ApplyFamilyNUSizingRDSWithExpiryOptions (recs , coverage , targetPct , commitments ,
434+ FamilyExpiryOptions {WindowDays : windowDays })
435+ }
436+
437+ // ApplyFamilyNUSizingRDSWithExpiryOptions is ApplyFamilyNUSizingRDSWithExpiry
438+ // that also credits replacements already bought. With R = the NU of visible
439+ // active, non-expiring RIs in the family that started within
440+ // opts.CoverageLookbackDays, the family's post-expiry covered NU is
441+ //
442+ // max(0, max(min(C, C - E + R), min(C, A - E)))
443+ //
444+ // where C is CoveredNU, E the expiring NU and A the active NU. R only raises
445+ // the first term, so the result is never below the R = 0 formula (the purchase
446+ // never grows), never above C, and never more than E below C. Replacement RIs
447+ // bought inside the lookback are in the lagged C only in part, so crediting
448+ // them in full can only under-count the gap, never over-count it. With full
449+ // inventory the min(C, A - E) term already counts them.
450+ func ApplyFamilyNUSizingRDSWithExpiryOptions (
451+ recs []common.Recommendation ,
452+ coverage PoolCoverageMap ,
453+ targetPct float64 ,
454+ commitments []common.Commitment ,
455+ opts FamilyExpiryOptions ,
409456) (sizedRDS , nonRDS []common.Recommendation , drops FamilyDropCounts , expiry FamilyExpiryReport ) {
410457 if targetPct <= 0 || targetPct > 100 {
411458 return nil , recs , drops , expiry
412459 }
413460 familyCov := AggregateRDSFamilyCoverage (coverage )
414- expiry = adjustFamilyCoverageForExpiry (familyCov , commitments , windowDays , time .Now ())
461+ expiry = adjustFamilyCoverageForExpiry (familyCov , commitments , opts , time .Now ())
415462 sizedRDS , nonRDS , drops = sizeRDSFamilies (recs , familyCov , targetPct )
416463 return sizedRDS , nonRDS , drops , expiry
417464}
418465
419466// adjustFamilyCoverageForExpiry mutates familyCov (a fresh aggregate owned by
420- // the caller) as described on ApplyFamilyNUSizingRDSWithExpiry .
421- func adjustFamilyCoverageForExpiry (familyCov map [string ]FamilyCoverage , commitments []common.Commitment , windowDays int , now time.Time ) FamilyExpiryReport {
467+ // the caller) as described on ApplyFamilyNUSizingRDSWithExpiryOptions .
468+ func adjustFamilyCoverageForExpiry (familyCov map [string ]FamilyCoverage , commitments []common.Commitment , opts FamilyExpiryOptions , now time.Time ) FamilyExpiryReport {
422469 var report FamilyExpiryReport
423- if windowDays <= 0 || len (commitments ) == 0 {
470+ if opts . WindowDays <= 0 || len (commitments ) == 0 {
424471 return report
425472 }
426- cutoff := now .Add (time .Duration (windowDays ) * 24 * time .Hour )
427- activeNU := make (map [string ]float64 )
428- expiringNU := make (map [string ]float64 )
473+ totals := tallyFamilyCommitments (familyCov , commitments , opts , now , & report )
474+ for fk , exp := range totals .expiring {
475+ fc := familyCov [fk ]
476+ rep := totals .replacement [fk ]
477+ fc .CoveredNU = math .Max (0 , math .Max (math .Min (fc .CoveredNU , fc .CoveredNU - exp + rep ), math .Min (fc .CoveredNU , totals .active [fk ]- exp )))
478+ familyCov [fk ] = fc
479+ report .ExpiringNU += exp
480+ report .ReplacementNU += rep
481+ }
482+ return report
483+ }
484+
485+ // familyNUTotals holds per-family normalized-unit sums over the inventory.
486+ type familyNUTotals struct {
487+ active map [string ]float64 // every active RI
488+ expiring map [string ]float64 // active RIs ending inside the rebuy window
489+ replacement map [string ]float64 // active, non-expiring RIs started inside the coverage lookback
490+ }
491+
492+ // tallyFamilyCommitments sums the inventory per family and counts expiring RIs
493+ // it cannot use into report.Unusable.
494+ func tallyFamilyCommitments (familyCov map [string ]FamilyCoverage , commitments []common.Commitment , opts FamilyExpiryOptions , now time.Time , report * FamilyExpiryReport ) familyNUTotals {
495+ cutoff := now .Add (time .Duration (opts .WindowDays ) * 24 * time .Hour )
496+ lagStart := now .Add (- time .Duration (opts .CoverageLookbackDays ) * 24 * time .Hour )
497+ t := familyNUTotals {
498+ active : make (map [string ]float64 ),
499+ expiring : make (map [string ]float64 ),
500+ replacement : make (map [string ]float64 ),
501+ }
429502 for i := range commitments {
430503 fk , units , expiring , ok := rdsCommitmentFamilyUnits (& commitments [i ], cutoff )
431504 if ! ok {
@@ -434,23 +507,20 @@ func adjustFamilyCoverageForExpiry(familyCov map[string]FamilyCoverage, commitme
434507 }
435508 continue
436509 }
437- activeNU [fk ] += units
510+ t . active [fk ] += units
438511 if ! expiring {
512+ if opts .CoverageLookbackDays > 0 && commitments [i ].StartDate .After (lagStart ) {
513+ t .replacement [fk ] += units
514+ }
439515 continue
440516 }
441517 if _ , known := familyCov [fk ]; ! known {
442518 report .Unusable ++
443519 continue
444520 }
445- expiringNU [fk ] += units
521+ t . expiring [fk ] += units
446522 }
447- for fk , exp := range expiringNU {
448- fc := familyCov [fk ]
449- fc .CoveredNU = math .Max (0 , math .Max (fc .CoveredNU - exp , math .Min (fc .CoveredNU , activeNU [fk ]- exp )))
450- familyCov [fk ] = fc
451- report .ExpiringNU += exp
452- }
453- return report
523+ return t
454524}
455525
456526// rdsCommitmentFamilyUnits maps an active RDS commitment to its family key and
0 commit comments