Skip to content

Commit 3a5b1f2

Browse files
aarthy-dkclaude
andcommitted
fix(monitors): event-space SARIMAX for freshness-gated volume/metric prediction
Freshness-gated Volume_Trend/Metric_Trend monitors still flagged legitimate refreshes: get_sarimax_forecast resampled the (irregular) freshness-filtered series onto a calendar grid and linearly interpolated the gaps, collapsing the refresh-jump variance and biasing the forecast low. Fit the gated series in event-space (one step per refresh, no interpolation) instead — preserving the SE floor and volume>=0 floor. Walk-forward on the real series: 6.1% -> 0% false positives, true over-loads still caught. Display: gated monitors render a flat baseline up to the predicted next-update window then a coupled step to the forecast, instead of a rising cone; the "show more history" toggle moved into the dialog header (new headerActions slot). TG-1131 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a75be88 commit 3a5b1f2

8 files changed

Lines changed: 426 additions & 82 deletions

File tree

testgen/commands/test_thresholds_prediction.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,12 @@ def compute_sarimax_threshold(
299299
exclude_weekends: bool = False,
300300
holiday_codes: list[str] | None = None,
301301
schedule_tz: str | None = None,
302+
event_space: bool = False,
302303
) -> tuple[float | None, float | None, str | None]:
303304
"""Compute SARIMAX-based thresholds for the next forecast point.
304305
306+
`event_space=True` fits in event-space (one step per observation, no interpolation).
307+
305308
Returns (lower, upper, forecast_json) or (None, None, None) if insufficient data.
306309
"""
307310
try:
@@ -311,6 +314,7 @@ def compute_sarimax_threshold(
311314
exclude_weekends=exclude_weekends,
312315
holiday_codes=holiday_codes,
313316
tz=schedule_tz,
317+
event_space=event_space,
314318
)
315319

316320
num_points = len(history)
@@ -359,13 +363,17 @@ def compute_volume_or_metric_threshold(
359363
Freshness_Trend detected a fingerprint change.
360364
"""
361365
filtered_history = history.loc[history["test_run_id"].astype(str).isin(freshness_updates)]
366+
# Event-space fit: the filtered series has one point per refresh and is irregularly spaced.
367+
# Calendar resampling + interpolation would smooth the refresh jumps into small uniform
368+
# increments and collapse the jump variance, biasing the forecast low and the band too tight.
362369
lower, upper, prediction = compute_sarimax_threshold(
363370
filtered_history,
364371
sensitivity=sensitivity,
365372
num_forecast=num_forecast,
366373
exclude_weekends=exclude_weekends,
367374
holiday_codes=holiday_codes,
368375
schedule_tz=schedule_tz,
376+
event_space=True,
369377
)
370378
if prediction is not None:
371379
# Pull the baseline value from the most-recent filtered row.

testgen/common/time_series_service.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def get_sarimax_forecast(
2323
exclude_weekends: bool = False,
2424
holiday_codes: list[str] | None = None,
2525
tz: str | None = None,
26+
event_space: bool = False,
2627
) -> pd.DataFrame:
2728
"""
2829
# Parameters
@@ -33,30 +34,48 @@ def get_sarimax_forecast(
3334
:param exclude_weekends: Whether weekends should be considered exogenous when training the model and forecasting.
3435
:param holiday_codes: List of country or financial market codes defining holidays to be considered exogenous when training the model and forecasting.
3536
:param tz: IANA timezone (e.g. "America/New_York") for day-of-week/holiday checks. Naive timestamps are treated as UTC and converted to this timezone before determining weekday/holiday status.
37+
:param event_space: When False (default), resample the series onto a regular calendar grid and
38+
linearly interpolate gaps before fitting — appropriate for series sampled at a
39+
steady cadence, and where weekend/holiday exogenous flags apply. When True, fit
40+
in event-space: one model step per observation, no interpolation, no calendar
41+
exog. Required for irregularly-spaced series (e.g. the freshness-update points of
42+
a refresh-driven table) where interpolation would smooth multi-period jumps into
43+
small uniform increments and collapse the very jump variance the forecast must
44+
capture.
3645
3746
# Return value
3847
Returns a Pandas dataframe with forecast DatetimeIndex, "mean" column, and "se" (standard error) column.
3948
"""
4049
if len(history) < MIN_TRAIN_VALUES:
4150
raise NotEnoughData("Not enough data points in history.")
4251

43-
# statsmodels requires DatetimeIndex with a regular frequency
44-
# Resample the data to get a regular time series
45-
datetimes = history.index.to_series()
46-
frequency = infer_frequency(datetimes)
47-
resampled_history = history.resample(frequency).mean().interpolate(method="linear")
48-
49-
if len(resampled_history) < MIN_TRAIN_VALUES:
50-
raise NotEnoughData("Not enough data points after resampling.")
52+
if event_space:
53+
# Event-space: one step per observation. Map the values onto a synthetic regular grid
54+
# stepped by the median observed interval, so forecast timestamps remain realistic while
55+
# the model sees each observation as a single step (no interpolated points between them).
56+
median_step = history.index.to_series().diff().median()
57+
if pd.isna(median_step) or median_step <= pd.Timedelta(0):
58+
median_step = pd.Timedelta(days=1)
59+
step = median_step
60+
synthetic_index = pd.date_range(end=history.index[-1], periods=len(history), freq=step)
61+
train_history = pd.DataFrame(history.iloc[:, 0].values, index=synthetic_index, columns=[history.columns[0]])
62+
else:
63+
# statsmodels requires DatetimeIndex with a regular frequency
64+
# Resample the data to get a regular time series
65+
datetimes = history.index.to_series()
66+
frequency = infer_frequency(datetimes)
67+
train_history = history.resample(frequency).mean().interpolate(method="linear")
68+
if len(train_history) < MIN_TRAIN_VALUES:
69+
raise NotEnoughData("Not enough data points after resampling.")
70+
step = pd.to_timedelta(frequency)
5171

5272
# Generate DatetimeIndex with future dates
53-
forecast_start = resampled_history.index[-1] + pd.to_timedelta(frequency)
54-
forecast_index = pd.date_range(start=forecast_start, periods=num_forecast, freq=frequency)
73+
forecast_index = pd.date_range(start=train_history.index[-1] + step, periods=num_forecast, freq=step)
5574

56-
# Detect holidays in entire date range
75+
# Detect holidays in entire date range (calendar-aware path only)
5776
holiday_dates = None
58-
if holiday_codes:
59-
all_dates_index = resampled_history.index.append(forecast_index)
77+
if not event_space and holiday_codes:
78+
all_dates_index = train_history.index.append(forecast_index)
6079
holiday_dates = get_holiday_dates(holiday_codes, all_dates_index)
6180

6281
def get_exog_flags(index: pd.DatetimeIndex) -> pd.DataFrame:
@@ -71,11 +90,13 @@ def get_exog_flags(index: pd.DatetimeIndex) -> pd.DataFrame:
7190
exog.loc[pd.Index(check_index.date).isin(holiday_dates), "is_excluded"] = 1
7291
return exog
7392

74-
exog_train = get_exog_flags(resampled_history.index)
93+
# Calendar exogenous flags only apply when fitting against real calendar time.
94+
exog_train = None if event_space else get_exog_flags(train_history.index)
95+
exog_forecast = None if event_space else get_exog_flags(forecast_index)
7596

7697
# When seasonal_order is not specified, this is effectively the ARIMAX model
7798
model = SARIMAX(
78-
resampled_history.iloc[:, 0],
99+
train_history.iloc[:, 0],
79100
exog=exog_train,
80101
# This is a good starting point according to Gemini - tune if needed
81102
order=(1, 1, 1),
@@ -85,12 +106,6 @@ def get_exog_flags(index: pd.DatetimeIndex) -> pd.DataFrame:
85106
)
86107
fitted_model = model.fit(disp=False)
87108

88-
forecast_index = pd.date_range(
89-
start=resampled_history.index[-1] + pd.to_timedelta(frequency),
90-
periods=num_forecast,
91-
freq=frequency
92-
)
93-
exog_forecast = get_exog_flags(forecast_index)
94109
forecast = fitted_model.get_forecast(steps=num_forecast, exog=exog_forecast)
95110

96111
results = pd.DataFrame(index=forecast_index)

testgen/ui/components/frontend/js/pages/table_monitoring_trends.js

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -123,18 +123,6 @@ const TableMonitoringTrend = (props) => {
123123
},
124124
div(
125125
{ class: '', style: 'width: 100%;' },
126-
() => {
127-
const extendedHistory = getValue(props.extended_history) ?? false;
128-
return div(
129-
{ class: 'extended-history-toggle' },
130-
Button({
131-
label: extendedHistory ? 'Show default view' : 'Show more history',
132-
icon: extendedHistory ? 'history_toggle_off' : 'history',
133-
width: 'auto',
134-
onclick: () => emit('ToggleExtendedHistory', { payload: {} }),
135-
}),
136-
);
137-
},
138126
() => {
139127
if (!getValue(props.dialog)?.open) return div();
140128
return ChartsSection(props, { schemaChartSelection, getDataStructureLogs });
@@ -229,12 +217,22 @@ const TableMonitoringTrend = (props) => {
229217
);
230218

231219
const dialogTitle = van.derive(() => getValue(props.dialog)?.title ?? '');
220+
const historyToggle = () => {
221+
const extendedHistory = getValue(props.extended_history) ?? false;
222+
return Button({
223+
label: extendedHistory ? 'Show default view' : 'Show more history',
224+
icon: extendedHistory ? 'history_toggle_off' : 'history',
225+
width: 'auto',
226+
onclick: () => emit('ToggleExtendedHistory', { payload: {} }),
227+
});
228+
};
232229
return Dialog(
233230
{
234231
title: dialogTitle,
235232
open: dialogOpen,
236233
onClose: () => { dialogOpen.val = false; emit('CloseTrendsDialog', {}); },
237234
width: '75rem',
235+
headerActions: historyToggle,
238236
},
239237
content,
240238
);
@@ -825,13 +823,6 @@ stylesheet.replace(`
825823
position: relative;
826824
}
827825
828-
.extended-history-toggle {
829-
position: absolute;
830-
top: -70px;
831-
right: 48px;
832-
z-index: 1;
833-
}
834-
835826
.table-monitoring-trend-wrapper:not(.has-sidebar) > .tg-dualpane-divider {
836827
display: none;
837828
}

testgen/ui/static/js/components/dialog.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* @property {import('../van.min.js').State<boolean>} open - Reactive open state
66
* @property {Function} onClose - Called when the dialog is closed (backdrop click or X button)
77
* @property {string} [width] - CSS width value, default '30rem'
8+
* @property {(Element | Function)} [headerActions] - Optional node rendered right-aligned in header row
89
*/
910
import van from '../van.min.js';
1011
import { getValue, loadStylesheet } from '../utils.js';
@@ -28,7 +29,7 @@ const { button, div, i, span } = van.tags;
2829
* @param {DialogProps} props
2930
* @param {...(Element | string)} children - Content rendered in the dialog body
3031
*/
31-
const Dialog = ({ title, open, onClose, width = '30rem' }, ...children) => {
32+
const Dialog = ({ title, open, onClose, width = '30rem', headerActions }, ...children) => {
3233
loadStylesheet('dialog', stylesheet);
3334

3435
const overlay = div(
@@ -50,6 +51,7 @@ const Dialog = ({ title, open, onClose, width = '30rem' }, ...children) => {
5051
div(
5152
{ class: 'tg-dialog-header' },
5253
span({ 'data-testid': 'dialog-title', class: 'tg-dialog-title' }, title),
54+
headerActions ? div({ class: 'tg-dialog-header-actions' }, headerActions) : null,
5355
),
5456
div({ 'data-testid': 'dialog-content', class: 'tg-dialog-content' }, ...children),
5557
button(
@@ -117,6 +119,12 @@ stylesheet.replace(`
117119
flex-shrink: 0;
118120
}
119121
122+
.tg-dialog-header-actions {
123+
margin-left: auto;
124+
font-size: initial;
125+
font-weight: initial;
126+
}
127+
120128
.tg-dialog-content {
121129
padding: 0.75rem 1.5rem 1.5rem;
122130
overflow-y: auto;

0 commit comments

Comments
 (0)