Skip to content

feat(aws/ladder): family-aware stable-usage estimate for the EC2 Instance SP base layer #731

Closed

Description

@cristim

Follow-up to #31 (stable-usage estimator, merged in #730).

GetUsageBaseline estimates StableUSDPerHour from the region's EC2 on-demand total across all instance families (the series built in recommendations/ondemand_series.go). An EC2 Instance Savings Plan is bound to one family, so a flat total whose family mix migrates (for example m5 to m7) still passes every gate and can strand a family-bound commitment.

Build a per-family (or per-family-group) stable-usage estimate, so the base layer can be sized by each family's own durable level. Open points: a per-family Cost Explorer series (cost and call volume), how the platform's builder splits Stable across families today (weights from stored recommendations), and whether the gate thresholds from #730 carry over per family.

Also worth checking while here: how often Cost Explorer lag leaves the last complete day partial, since the estimator's 3-day minimum turns a partial trailing day into a low or nil estimate (safe, but the base layer can be intermittently absent), and the weekend-dip case (a weekday/weekend ratio below 0.5 returns nil on every run).

Activity

  1. cristim commented on Nov 12, 2026

    @cristim
    MemberAuthor

    Plan (reviewed: 3 adversarial passes, same reviewer, verdict PLAN OK)

    Challenge: on origin/main f1ffe43, the base layer is still flat. providers/aws/ladder/baseline.go:81 fetches one region-wide daily series, and baseline.go:307-315 sets StableUSDPerHour via estimateStableUSDPerHour (providers/aws/ladder/stable_usage.go:74-89). Not family-aware, so proceeding.

    Change:

    1. providers/aws/recommendations: new per-account fetch of daily on-demand cost grouped by INSTANCE_TYPE (one GetCostAndUsage, DAILY, same SERVICE/PURCHASE_TYPE/REGION/LINKED_ACCOUNT filter). Family = part before the first .. NoInstanceType skipped by named constant. Days CE returns are zero-filled for families absent that day. Unverified against live CE.
    2. providers/aws/ladder: optional family source wired only from the factory. The flat path is unchanged when it is nil.
    3. Estimate, when the family source is set and buffer is configured: no families, or one family after the skip, returns the flat estimate. Two or more families return min(Flat, sum_f min(series_f) * (1 - buffer)), or nil when Flat is nil.

    Tests: (a) never exceeds the sum of per-family minimums; (b) no family data falls back to flat, and nil flat stays nil; (c) single family equals flat exactly. Mutation probes: min to max in the per-family minimum, and removing the flat clamp. Each must fail a test.

    Commits: C1 fetch and adapter; C2 estimator and wiring. Both reference #731 but do not close it: the builder split across families and the per-family gate carry-over remain open.

  2. cristim commented on Nov 12, 2026

    @cristim
    MemberAuthor

    Landed on main (fast-forward), Refs #731, issue left open:

    • 683bf14 feat(aws): fetch on-demand spend per EC2 family for the stable base (per-family daily fetch, account-scoped, grouped by INSTANCE_TYPE; tests TestGetOnDemandFamilySeries_*).
    • 7a49f89 feat(aws/ladder): family-aware stable estimate for the SP base layer (min(flat, sum of family minimums less the buffer) with two or more families; tests TestFamilyStable_*, TestGetUsageBaseline_FamilyPathSetsStable).

    Decisions recorded (Blockers rule, owner go 2026-11-12 decide-now list): a family fetch error falls back to the flat estimate with a WARNING log; one or zero families keep the flat value.

    Not covered by this change, so the issue stays open: the builder split across families, per-family gate carry-over, partial trailing day and weekend-dip acceptance points. Unverified risks: the DAILY GroupBy INSTANCE_TYPE shape on GetCostAndUsage has not been checked against live Cost Explorer; it adds one Cost Explorer call per baseline run; a single volatile family still falls back to flat, which is less conservative than dropping that family.

  3. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    Plan: per-family gate carry-over (open point b of #731)

    Chosen point: (b). A family that fails the #730 single-series gate must contribute nothing to the family sum.

    Challenge (origin/main 0b5bd15): providers/aws/ladder/stable_usage.go:120 adds slices.Min(series)*(1-f) for every family with no gate. A volatile family (stable_usage.go:61-63 fails: p10 < 0.5*median) or a series shorter than minStableSeriesDays still adds its minimum. Not already handled.

    Change:

    1. stable_usage.go familyStableUSDPerHour: per family, compute stableQuantiles; if stableGatePasses fails, add nothing; otherwise add slices.Min(series)*(1-f). Validation stays before the gate. nil when total <= 0 or flat nil (unchanged). Update the doc comment (lines 94-101).
    2. stable_usage_family_test.go: rewrite fixtures that would now fail the gate (NeverExceedsSumOfFamilyMinimums: uniform 0-100 becomes 50+rng*20; CappedByFlat and NilFlatStaysNil: length 3 becomes length 14+).

    Tests (new): volatile family dropped, estimate equals steady family alone; 13-day family contributes nothing; all families failing the gate gives nil; p10 == 0.5*median exactly passes (pins >=).

    Direction: dropping a non-negative term can only lower the sum, so the change never raises the estimate.

    Mutation probe: bypass the gate check; the volatile-family test must fail. Restore from backup, verify with cmp.

    Commit: one commit, Refs #731 (issue stays open). Still open after this: (a) builder split across families, (c) partial trailing day, (d) weekend-dip.

  4. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    Landed on main as 86cff2d (fast-forward, Refs #731, issue left open).

    Decision: point (b), per-family gate carry-over. A family that fails the #730 single-series gate (n >= 14, median > 0, p10 >= 0.5 * median) now contributes nothing to the family sum, instead of its minimum. Dropping a non-negative term can only lower the estimate, so this is the conservative direction. Validation still runs first, so bad family series still fail loud. The "one family" path is unchanged.

    Tests (providers/aws/ladder/stable_usage_family_test.go):

    • TestFamilyStable_VolatileFamilyContributesNothing, TestFamilyStable_ShortFamilyContributesNothing, TestFamilyStable_AllFamiliesFailGateReturnsNil: fail on the pre-fix code (9.9, 12.6 and 4.5 respectively), pass after.
    • TestFamilyStable_GateBoundaryPasses pins >=; it passes both before and after. Mutation to > fails it.
    • Fixtures in NeverExceedsSumOfFamilyMinimums, CappedByFlat and NilFlatStaysNil now pass the gate, so they still test what they claim.
    • Mutation probe (gate bypassed): three rule tests fail. Restored and verified by cmp.

    Verification: GOWORK=off go test -count=1 ./... exit 0; go vet, gofmt -l, golangci-lint: clean (0 issues). Two adversarial review passes, same reviewer, no findings on either pass.

    Still open on #731:

    • (a) the builder split across families (weights from stored recommendations)
    • (c) partial trailing day handling (needs live Cost Explorer data to measure how often it happens)
    • (d) weekend-dip handling (a weekday/weekend ratio below 0.5 still returns nil; relaxing it would be less conservative, so this needs an owner decision)
  5. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    Point (a) blocked: no split exists to weight, and stored recs are not an input to the base layer.

    Challenge on origin/main (fetched this turn):

    • No split across families exists. providers/aws/ladder/stable_usage.go:103-133 (familyStableUSDPerHour) returns min(flat, sum over families of min(series) * (1 - f)). It is a single scalar sum of per-family minimums. There is no equal split and no per-family allocation of a base amount to weight.
    • The base gap is a single number. pkg/ladder/allocate.go:806-838 (computeBaseGap) routes one stableLeft value to the base layer. No code assigns that value to families.
    • Stored recommendations are not read by the baseline. providers/aws/ladder/baseline.go:301-353 (baselineStats, familyStable) takes only the CE daily series and the flat estimate. Nothing in providers/aws/ladder or pkg/ladder reads stored recs during GetUsageBaseline.
    • The only per-family data on a stored rec is SavingsPlanDetails.InstanceFamily and HourlyCommitment (pkg/common/types.go, EC2Instance SP recs). That is a CE-sized commitment from a past run, not a usage weight, and it is absent for non-SP recs.

    Decision (Blockers rule): stop, no code. Wiring stored recs into the baseline path plus a new per-family base allocator is a design change, not the smallest self-contained slice, so it needs the owner's call. Options for the owner:

    1. Define the split as a new allocator: distribute the base amount in proportion to each family's stable minimum (usage-weighted, no stored recs needed). Smallest; does not use stored recs.
    2. Use stored EC2Instance SP HourlyCommitment per family as weights. Needs the baseline to read stored recs, a new input and a staleness rule.

    Still open on #731: (a) as above, (c) partial trailing day, (d) weekend-dip. Issue stays open. Worktree ~/.claude/worktrees/go-731-builder (branch feat/731-builder, from origin/main) is left in place, empty of commits.

  6. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    Decision on the builder point (a) (Blockers rule, claude-go-cli-mcp, 2026-11-13): option (1). Split the stable base across families in proportion to each family's stable minimum, computed from the daily series the baseline already reads. No stored recommendations are needed, and the single-family and no-data cases keep the current behaviour. Option (2) would make the baseline read stored recommendations and is not the smallest slice. Implementation to follow in a separate slice; this issue stays open.

  7. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    Point (a) on hold: plan review found the slice is dead data, so nothing is landed.

    Plan reviewed by one non-author reviewer (sonnet, high). Facts and ordering checked out on origin/main 1aa9d7b: computeBaseGap still reads one scalar (pkg/ladder/allocate.go:811), the family minimums are available from the daily series (providers/aws/ladder/stable_usage.go, baseline.go familyStable), and providers/aws builds against the published pkg pin under GOWORK=off, so a new pkg/ladder field needs the two-commit pin bump.

    Blocker: a per-family share carried on UsageBaseline has no consumer. computeBaseGap ignores it, so sizing output does not change. The split only has an effect once the allocator emits per-family base rows, which needs a family dimension in pkg/ladder allocations and purchase rows. That is a design change larger than one slice, so I did not land the field or the pin bump.

    Also found: base*c/total can exceed base by 1 ulp when one family carries the whole total. The implementation would clamp with min(share, base).

    Options for the owner: (1) take the allocator family dimension as the next slice, scoped in its own plan, and land the split with it; (2) accept the carrier-only slice now (field, pin bump, provider split) as pre-staging. The reviewer and I lean to (1).

    Worktree ~/.claude/worktrees/go-731-split (branch feat/731-split, from origin/main 1aa9d7b) holds the uncommitted draft and is kept. Issue stays open.

  8. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    Decision on the blocker (Blockers rule, claude-go-cli-mcp, 2026-11-13): option (1). The allocator family dimension is the next slice, with its own plan and review, and the split lands together with it. The uncommitted draft in ~/.claude/worktrees/go-731-split stays for reference and is not pushed. The option (2) pre-staging is not taken, since a carrier field with no consumer would be dead data. Issue stays open.

  9. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    Closed by the family dimension plus the split, landed on main (fast-forward, no PR):

    • 62a952f carry an instance family through allocations, tranches and plan actions
    • b7da46f split the EC2 Instance SP base gap across instance families (UsageBaseline.StableUSDPerHourByFamily, absolute shares summing to StableUSDPerHour; fail loud on a malformed map)
    • b5ff9a6 net in-flight base tranches per instance family (headroom-capped, so no row passes its family's stable level)
    • 349e37b providers/aws publishes the per-family split; providers/aws re-pinned to pkg 5525eb3
    • dde2ecc re-pin azure and gcp (standalone CI had broken on pkg term spellings from another change)

    CI: green on b5ff9a6 (runs 43485501121, 43485501385) and on dde2ecc (43492081261, 43492081212). Two adversarial reviewer passes plus a confirmation pass on the pkg diff (opus), and two passes on the provider diff (tree 534aac1c), findings fixed before each push. Plan went through several review rounds (final rounds clean).

    Decisions recorded under the Blockers rule (owner can override): a family fetch error or no family data now leaves no stable base (WARNING logged, gap routes to flex) instead of falling back to the flat estimate, because a family-bound commitment must never be sized without a family; a single family now gets min(its own minimum, flat) and its own gate; each family row takes one MaxActionsPerRun slot; existing base on a total-basis baseline withholds the family base (AWS baselines are residual so it never fires there).

    Evidence is fixtures and loopback Cost Explorer responses only. Live Cost Explorer (DAILY, GroupBy INSTANCE_TYPE) is unverified. Until the platform sets InFlightTranche.Family, in-flight netting is by share, not per-family headroom (total still bounded).

    Still open, tracked in #746: (c) partial trailing day, (d) weekend dip, per-family existing commitment, and the platform store/executor work (do not bump pkg/providers/aws in the platform before it reads Tranche.Family; PurchaseLayer has no cross-check of the family).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions