Skip to content

feat(ladder): fail-loud stale/insufficient baseline series detection (L4) - #1365

Merged
cristim merged 4 commits into
mainfrom
feat/ladder-stale-series
Jul 16, 2026
Merged

feat(ladder): fail-loud stale/insufficient baseline series detection (L4)#1365
cristim merged 4 commits into
mainfrom
feat/ladder-stale-series

Conversation

@cristim

@cristim cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member

What

Work item L4 of the auto-laddering full-automation plan (closes gap G6a). Adds fail-loud stale/insufficient-series detection to the AWS ladder usage baseline so untrustworthy Cost Explorer data cannot silently drive a commitment purchase.

Today GetUsageBaseline computes a low-water baseline from a CE time series with no freshness or completeness guard: a stale or sparse series silently yields a baseline that the utilization clamp then trusts. This adds three guards, all returning an explicit error (never a degraded/nil baseline), so the config is counted Errored and surfaced rather than silently held or over-committed.

Guards (named constants, no magic numbers)

  • Freshness (maxSeriesAgeDays = 3): errors if the latest point is more than 3 UTC calendar days old.
  • Completeness (maxMissingDays = 3): errors if the series has fewer than lookbackDays - 3 points.
  • Chronology: validateSeriesChronology requires strictly-increasing calendar dates, failing loud on non-monotonic or duplicate-date input, so the last element is provably the latest and duplicate rows the count-based check can't see are rejected.
  • The existing minBaselineSeriesDays = 7 absolute floor still fires first for very short series.

Scope

Only the freshness/completeness/chronology guard is added; the daily-average percentile math is unchanged (the hourly-granularity baseline_granularity enum is a separate item, L7). The onDemandSeriesSource.GetOnDemandSeries interface return type changes []float64 -> []DailyPoint{Date, USDPerHour} so freshness can be checked against real dates.

Verification (exit 0)

  • go build ./..., go vet ./... clean (root + providers/aws).
  • go test ./ladder/... 121 pass, gocyclo -over 10 clean.
  • Fail-loud verified against the consumer: a nil LowWaterUSDPerHour is a benign Hold in pkg/ladder, while an error from GetUsageBaseline propagates as Errored — the guards return errors, so untrustworthy data is never silently held.
  • New tests (fail-before / pass-after confirmed): stale-series, sparse-series, non-monotonic/duplicate-date -> error; exact-boundary (age==3, count==lookback-3) -> pass; fresh+complete happy path unchanged; all 9 pre-existing baseline tests still assert their original guards.

Coordination note

The GetOnDemandSeries signature change ([]float64 -> []DailyPoint) touches onDemandSeriesSource. PR #1362 (feat/ladder-run-scheduler) adds providers/aws/ladder/factory.go with a noop implementation of this interface; whichever of the two merges second will need a trivial update to factory.go's noop to return []DailyPoint. Item L2 wires the real Cost-Explorer-backed adapter against this signature.

Part of #1336. Tracker #1333.

Summary by CodeRabbit

  • Bug Fixes
    • Improved usage baseline accuracy by validating the on-demand daily series for sufficient in-window UTC-day coverage.
    • Rejects stale data, non-monotonic or duplicate dates, and series with excessive missing days.
    • Continued protection against invalid usage values (negative, non-numeric, or infinite), with clearer index-based error reporting.
  • Reliability
    • Baseline calculations now rely on explicit calendar dates to ensure series freshness and ordering before computing percentile results.

@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/medium Moderate harm urgency/this-quarter Within the quarter impact/many Affects most users effort/m Days type/feat New capability labels Jul 16, 2026
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 528808db-28cc-4f3c-a8ce-b2be006e33f5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

AWSLadder.GetUsageBaseline now consumes dated daily points and rejects insufficient, stale, unordered, duplicate, non-finite, or negative series before calculating percentiles. Tests cover the new data contract, validation boundaries, and regression cases.

Changes

Daily baseline validation

Layer / File(s) Summary
Daily point contract
providers/aws/ladder/interfaces.go, providers/aws/ladder/factory.go, providers/aws/ladder/baseline.go
On-demand data is represented as dated DailyPoint values, with configurable missing-day and staleness thresholds; the noop source returns the updated type.
Baseline validation flow
providers/aws/ladder/baseline.go
GetUsageBaseline validates coverage, chronology, freshness, and extracted USD-per-hour values before percentile computation.
Baseline regression coverage
providers/aws/ladder/ladder_test.go
Fakes, helpers, existing tests, and new regressions cover stale, sparse, boundary, unordered, duplicate, and invalid-value series.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OnDemandSeriesSource
  participant AWSLadder.GetUsageBaseline
  participant validateInWindowCoverage
  participant validateSeriesFreshness
  participant extractValues
  OnDemandSeriesSource->>AWSLadder.GetUsageBaseline: return []DailyPoint
  AWSLadder.GetUsageBaseline->>validateInWindowCoverage: validate UTC-day coverage
  validateInWindowCoverage-->>AWSLadder.GetUsageBaseline: coverage result
  AWSLadder.GetUsageBaseline->>validateSeriesFreshness: validate chronology and age
  validateSeriesFreshness-->>AWSLadder.GetUsageBaseline: freshness result
  AWSLadder.GetUsageBaseline->>extractValues: extract USDPerHour values
  extractValues-->>AWSLadder.GetUsageBaseline: []float64
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: stricter ladder baseline validation for stale or insufficient series data.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ladder-stale-series

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@providers/aws/ladder/baseline.go`:
- Around line 154-160: The freshness validation around latestDate in
providers/aws/ladder/baseline.go lines 154-160 must normalize the latest data
point and today to UTC midnight, reject future latest dates, and compute ageDays
from their calendar-day difference instead of elapsed hours. Update the boundary
test in providers/aws/ladder/ladder_test.go lines 873-887 to construct the test
date from UTC midnight using AddDate(0, 0, -maxSeriesAgeDays), eliminating time
dependence.
- Around line 94-102: Update the coverage check in GetUsageBaseline to count
only unique points whose dates fall within the requested lookback window, or
validate the covered calendar span directly before applying minRequired. Ensure
points outside the window cannot satisfy the lookback coverage threshold while
preserving the existing error behavior and message context.

In `@providers/aws/ladder/ladder_test.go`:
- Around line 873-887: The TestGetUsageBaseline_AgeExactlyAtBoundary_NoError
setup currently depends on the current UTC hour when calculating end. Construct
end from the expected UTC calendar date directly, offset by maxSeriesAgeDays
from today, so the generated series is exactly the boundary age regardless of
when the test runs; leave the assertions and point generation unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 773944d5-4fa1-4986-9fdc-44d59323d471

📥 Commits

Reviewing files that changed from the base of the PR and between 79642c4 and 10e2596.

📒 Files selected for processing (3)
  • providers/aws/ladder/baseline.go
  • providers/aws/ladder/interfaces.go
  • providers/aws/ladder/ladder_test.go

Comment thread providers/aws/ladder/baseline.go Outdated
Comment thread providers/aws/ladder/baseline.go Outdated
Comment thread providers/aws/ladder/ladder_test.go
cristim added a commit that referenced this pull request Jul 16, 2026
Address CodeRabbit review on the stale-series guard (PR #1365):

1. Coverage check counted TOTAL points, so a series with enough points
   smeared across more than lookbackDays days (e.g. 27 points over 53
   days) passed despite sparse in-window coverage. New
   validateInWindowCoverage counts only points whose date falls within
   [today-lookbackDays, today] and requires that count >= lookbackDays -
   maxMissingDays; fail-loud otherwise.

2. Freshness derived "days old" from elapsed hours (floor(hours/24)),
   making the result time-of-day dependent and contradicting the
   calendar-day contract. Age is now the whole-UTC-calendar-day
   difference between today and the latest point, both normalized via the
   new utcDay helper. today is computed once in GetUsageBaseline and
   shared by both the coverage window and the freshness check for
   consistent date math.

3. Test hardening: the freshness/stale/recent date helpers built dates
   via hour arithmetic before truncation, which selected the wrong
   calendar day between 00:00-00:59 UTC and intermittently failed. All
   date-based test fixtures now build UTC calendar days directly.

New regression test:
  TestGetUsageBaseline_InsufficientInWindowCoverage_ReturnsError -- 27
  total points, only 20 in-window -> error (fails on the old total-count
  check, passes now).
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added 4 commits July 16, 2026 15:59
Introduce two named-constant guards in GetUsageBaseline that fail loud
when the CE on-demand series cannot be trusted:

  maxMissingDays = 3  -- coverage check: series must have >= lookbackDays-3
                         points (CE lags ~24-48 h; up to 3 absent days ok)
  maxSeriesAgeDays = 3 -- freshness check: most-recent DailyPoint must be
                         within 3 calendar days of now; older means the feed
                         stalled or the date range is wrong

Both conditions return an explicit error (not a zeroed/nil baseline) so
the engine config is counted Errored and surfaced to the operator rather
than silently trusting a stale low-water clamp.

Interface change: onDemandSeriesSource.GetOnDemandSeries now returns
[]DailyPoint{Date, USDPerHour} instead of []float64, giving
GetUsageBaseline the Date field it needs for the freshness check. L2
(real CE wiring) will implement this updated signature. Float64 values
are extracted via extractValues() before passing to the unchanged
validateSeries / nearestRankPercentile helpers.

Regression tests added:
  TestGetUsageBaseline_StaleSeries_ReturnsError   -- tail > maxSeriesAgeDays old
  TestGetUsageBaseline_SparseSeries_ReturnsError  -- too few points for lookback
  TestGetUsageBaseline_FreshAndCompleteSeries_NoError -- happy path unbroken
…s check

Fable review hardening for the stale-series guard. validateSeriesFreshness
trusted the documented oldest-to-newest ordering by reading the last
element as the newest point. A future L2 CE adapter returning unsorted or
duplicate-date pages could make a stale tail pass (fresh point last) or a
fresh series falsely error.

Add validateSeriesChronology: reject any non-monotonic or duplicate
calendar date (compared after UTC-day truncation) with an explicit error
naming the offending index and dates. Runs before the age check, so the
last element is provably the latest. It also catches duplicate-date rows
the count-based coverage check cannot see (N points spanning < N days).

Boundary tests lock both off-by-ones:
  TestGetUsageBaseline_AgeExactlyAtBoundary_NoError      -- age == max passes
  TestGetUsageBaseline_CoverageExactlyAtBoundary_NoError -- 27 points pass
  TestGetUsageBaseline_NonMonotonicDates_ReturnsError    -- swap + dup date
Address CodeRabbit review on the stale-series guard (PR #1365):

1. Coverage check counted TOTAL points, so a series with enough points
   smeared across more than lookbackDays days (e.g. 27 points over 53
   days) passed despite sparse in-window coverage. New
   validateInWindowCoverage counts only points whose date falls within
   [today-lookbackDays, today] and requires that count >= lookbackDays -
   maxMissingDays; fail-loud otherwise.

2. Freshness derived "days old" from elapsed hours (floor(hours/24)),
   making the result time-of-day dependent and contradicting the
   calendar-day contract. Age is now the whole-UTC-calendar-day
   difference between today and the latest point, both normalized via the
   new utcDay helper. today is computed once in GetUsageBaseline and
   shared by both the coverage window and the freshness check for
   consistent date math.

3. Test hardening: the freshness/stale/recent date helpers built dates
   via hour arithmetic before truncation, which selected the wrong
   calendar day between 00:00-00:59 UTC and intermittently failed. All
   date-based test fixtures now build UTC calendar days directly.

New regression test:
  TestGetUsageBaseline_InsufficientInWindowCoverage_ReturnsError -- 27
  total points, only 20 in-window -> error (fails on the old total-count
  check, passes now).
PR #1362 merged to main with factory.go's noopOnDemandSeriesSource
returning []float64, while this branch changed the onDemandSeriesSource
interface to return []DailyPoint (Date-carrying points for the
stale-series freshness check). Merging without this alignment would
break main despite no textual conflict (semantic conflict).

Update the noop's return type to []DailyPoint. Behavior is unchanged:
it still fails loud with the same not-yet-wired error, so plan runs
against the unwired factory continue to be counted Errored.
@cristim
cristim force-pushed the feat/ladder-stale-series branch from d9d265e to 4aff31f Compare July 16, 2026 13:01
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main (7adacd7, post-#1362) and aligned the newly-merged factory.go noop GetOnDemandSeries to the []DailyPoint signature this PR introduces (fail-loud behavior unchanged; audit confirmed it was the only implementer needing alignment). Zero textual conflicts; full gates green on the rebased branch (build/vet 0, providers/aws ladder 122, internal/server 401, gocyclo clean). New head 4aff31f. This resolves the semantic conflict that would have broken main had #1365 merged un-rebased.

@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 9dc87ef into main Jul 16, 2026
14 of 17 checks passed
@cristim
cristim deleted the feat/ladder-stale-series branch July 16, 2026 13:14
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Merged to main after: Fable review (SHIP + hardening round), 3 CodeRabbit findings fixed (in-window coverage counting, UTC calendar-day freshness, hour-independent boundary tests), rebase onto post-#1362 main with the factory.go noop aligned to []DailyPoint (resolving the semantic conflict pre-merge), CR-clean re-review on the rebased head, and full functional CI green. The baseline now fails loud on stale, sparse, non-monotonic, or insufficient in-window Cost Explorer data. Next in the ladder chain: L2 (real CE read adapters into the factory) is in implementation against this []DailyPoint interface.

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

Labels

effort/m Days impact/many Affects most users priority/p1 Next up; this sprint severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant