Skip to content

Commit e091289

Browse files
author
ci bot
committed
Merge branch 'fix/freshness-staleness-active-schedule' into 'enterprise'
fix(monitors): store freshness staleness threshold for full-week active schedules See merge request dkinternal/testgen/dataops-testgen!559
2 parents 2e3788f + e715c55 commit e091289

3 files changed

Lines changed: 74 additions & 4 deletions

File tree

testgen/commands/test_thresholds_prediction.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def run(self) -> None:
8989

9090
# Freshness update events are fetched as secondary data only when the suite
9191
# is a monitor — Volume_Trend / Metric_Trend in monitor suites couple to the
92-
# Freshness_Trend signal to avoid stairstep false positives.
92+
# Freshness_Trend signal to avoid stairstep false positives.
9393
freshness_updates_by_table: dict[tuple[str, str], list[str]] = (
9494
self._fetch_freshness_updates_by_table() if self.test_suite.is_monitor else {}
9595
)
@@ -268,10 +268,16 @@ def compute_freshness_threshold(
268268
window_end=schedule.window_end if has_window else None,
269269
)
270270
lower, upper = result.lower, result.upper
271-
staleness = result.staleness
272271
except NotEnoughData:
273272
pass # Keep first-pass thresholds
274273

274+
# An active schedule certifies the cadence is regular, so the tight staleness
275+
# threshold (median * factor) applies. Full-week schedules skip the recompute
276+
# above and use the first-pass result — with no excluded days, those gaps are
277+
# already in business minutes. Non-active stages keep staleness = None so the
278+
# prediction SQL falls back to upper_tolerance (avoids false weekend anomalies).
279+
staleness = result.staleness
280+
275281
# Override upper threshold with schedule-based deadline (daily/weekly only)
276282
if schedule.frequency != "sub_daily":
277283
holiday_dates = resolve_holiday_dates(holiday_codes, history.index) if holiday_codes else None
@@ -344,7 +350,7 @@ def compute_volume_or_metric_threshold(
344350
This avoids the "stairstep" false-positive shape where inter-change plateaus collapse the SE estimate.
345351
The returned prediction JSON is augmented with `freshness_gated` and `baseline_value` so
346352
that test execution can apply dual-branch evaluation.
347-
353+
348354
If the filtered fit fails for any reason, falls back to fit SARIMAX on
349355
the raw value series and emits a prediction JSON without the freshness-gating markers.
350356
@@ -381,5 +387,5 @@ def compute_volume_or_metric_threshold(
381387
exclude_weekends=exclude_weekends,
382388
holiday_codes=holiday_codes,
383389
schedule_tz=schedule_tz,
384-
)
390+
)
385391
return lower, upper, None, prediction

tests/unit/common/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,24 @@ def _gen_subdaily_gap_schedule_phase() -> list[tuple[pd.Timestamp, float]]:
294294
return _to_csv_rows(_make_observations(start, end, 2, updates))
295295

296296

297+
def _gen_twice_daily_outage() -> list[tuple[pd.Timestamp, float]]:
298+
"""Updates every 12h (00:00/12:00 UTC), 7 days a week, 5 weeks, then updates stop.
299+
300+
The 12h cadence classifies as "daily" frequency with a full-week active schedule,
301+
so no excluded days or sub-daily window apply — staleness must come from the
302+
first-pass gap thresholds.
303+
"""
304+
start = datetime(2025, 10, 6, 0, 0)
305+
end = datetime(2025, 11, 11, 12, 0)
306+
outage_start = datetime(2025, 11, 10, 0, 0)
307+
updates: set[datetime] = set()
308+
d = start
309+
while d < outage_start:
310+
updates.add(d)
311+
d += timedelta(hours=12)
312+
return _to_csv_rows(_make_observations(start, end, 12, updates))
313+
314+
297315
def _gen_weekly_early() -> list[tuple[pd.Timestamp, float]]:
298316
start = datetime(2025, 8, 7, 10, 0)
299317
end = datetime(2025, 11, 6, 22, 0)

tests/unit/common/test_freshness_scenarios.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
_gen_subdaily_gap_schedule_phase,
2626
_gen_subdaily_regular,
2727
_gen_training_only,
28+
_gen_twice_daily_outage,
2829
_gen_weekly_early,
2930
_run_scenario,
3031
)
@@ -472,3 +473,48 @@ def test_recovery_passes(self, results: list[ScenarioPoint]) -> None:
472473
assert post_recovery[0].result_code == 0
473474
# Second update after recovery should pass
474475
assert post_recovery[1].result_code == 1
476+
477+
478+
# ─── Scenario 9: Twice-Daily Outage (full-week schedule) ────────────
479+
480+
481+
class Test_TwiceDailyOutage:
482+
"""Updates every 12h around the clock for 5 weeks, then updates stop.
483+
484+
Full-week "daily"-frequency schedule: no excluded days and no sub-daily
485+
window, so the staleness threshold must come from the first-pass gap
486+
computation. With a 720-min median gap, staleness = 720 * 0.85 = 612 min,
487+
so the first missed check (gap 720 min) flags Late.
488+
"""
489+
490+
@pytest.fixture(scope="class")
491+
def results(self) -> list[ScenarioPoint]:
492+
rows = _gen_twice_daily_outage()
493+
return _run_scenario(rows, PredictSensitivity.medium, exclude_weekends=False, tz="UTC")
494+
495+
def test_schedule_goes_active_all_days(self, results: list[ScenarioPoint]) -> None:
496+
sched = _schedule(_updates(results)[-1])
497+
assert sched is not None
498+
assert sched.get("schedule_stage") == "active"
499+
assert sched.get("active_days") == [0, 1, 2, 3, 4, 5, 6]
500+
501+
def test_staleness_stored_once_active(self, results: list[ScenarioPoint]) -> None:
502+
last_update = _updates(results)[-1]
503+
assert last_update.staleness == pytest.approx(720 * 0.85)
504+
505+
def test_no_anomalies_before_outage(self, results: list[ScenarioPoint]) -> None:
506+
outage_start = pd.Timestamp("2025-11-10")
507+
assert all(p.timestamp >= outage_start for p in _anomalies(results))
508+
509+
def test_first_missed_check_flags_late(self, results: list[ScenarioPoint]) -> None:
510+
anomalies = _anomalies(results)
511+
assert len(anomalies) > 0
512+
first = anomalies[0]
513+
assert first.timestamp == pd.Timestamp("2025-11-10 00:00")
514+
assert first.value == 720
515+
516+
def test_all_missed_checks_flag(self, results: list[ScenarioPoint]) -> None:
517+
outage_start = pd.Timestamp("2025-11-10")
518+
missed_checks = [p for p in results if p.timestamp >= outage_start]
519+
assert len(missed_checks) == 4
520+
assert all(p.result_code == 0 for p in missed_checks)

0 commit comments

Comments
 (0)