From 99482e5eba9ab34c8d67cfa8cb52a7de31007e0c Mon Sep 17 00:00:00 2001 From: stacknil Date: Sun, 17 May 2026 15:17:08 +0800 Subject: [PATCH] Validate output table window bounds --- src/telemetry_window_demo/io.py | 23 +++++++++++++++ tests/test_io.py | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/telemetry_window_demo/io.py b/src/telemetry_window_demo/io.py index 97c0a2e..614ae4e 100644 --- a/src/telemetry_window_demo/io.py +++ b/src/telemetry_window_demo/io.py @@ -108,6 +108,7 @@ def load_feature_table(path: str | Path) -> pd.DataFrame: FEATURE_TABLE_NUMERIC_COLUMNS, source=str(table_path), ) + _require_window_bounds(frame, source=str(table_path)) return frame @@ -120,6 +121,8 @@ def load_alert_table(path: str | Path) -> pd.DataFrame: ALERT_TABLE_DATETIME_COLUMNS, source=str(table_path), ) + _require_window_bounds(frame, source=str(table_path)) + _require_alert_time_bounds(frame, source=str(table_path)) _require_text_columns(frame, ALERT_TABLE_TEXT_COLUMNS, source=str(table_path)) return frame @@ -209,6 +212,26 @@ def _parse_numeric_columns( frame[column] = parsed.astype("int64" if require_integer else "float64") +def _require_window_bounds(frame: pd.DataFrame, *, source: str) -> None: + invalid_windows = frame["window_end"] <= frame["window_start"] + if invalid_windows.any(): + raise ValueError( + f"Window end must be after window start in {source}: " + f"{int(invalid_windows.sum())} row(s)" + ) + + +def _require_alert_time_bounds(frame: pd.DataFrame, *, source: str) -> None: + out_of_bounds = (frame["alert_time"] < frame["window_start"]) | ( + frame["alert_time"] > frame["window_end"] + ) + if out_of_bounds.any(): + raise ValueError( + f"Alert time must fall within window bounds in {source}: " + f"{int(out_of_bounds.sum())} row(s)" + ) + + def _require_text_columns( frame: pd.DataFrame, text_columns: tuple[str, ...], diff --git a/tests/test_io.py b/tests/test_io.py index 969e440..8f54159 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -294,6 +294,22 @@ def test_load_feature_table_rejects_out_of_range_error_rate(tmp_path) -> None: assert "error_rate" in message +def test_load_feature_table_rejects_non_positive_window_bounds(tmp_path) -> None: + path = tmp_path / "features.csv" + path.write_text( + "window_start,window_end,event_count,error_rate\n" + "2026-03-10T10:01:00Z,2026-03-10T10:01:00Z,10,0.25\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError) as excinfo: + load_feature_table(path) + + message = str(excinfo.value) + assert "Window end must be after window start" in message + assert "1 row(s)" in message + + def test_load_alert_table_rejects_invalid_csv(tmp_path) -> None: path = tmp_path / "alerts.csv" path.write_text( @@ -344,6 +360,40 @@ def test_load_alert_table_rejects_missing_alert_timestamp(tmp_path) -> None: assert "alert_time" in message +def test_load_alert_table_rejects_non_positive_window_bounds(tmp_path) -> None: + path = tmp_path / "alerts.csv" + path.write_text( + "alert_time,window_start,window_end,rule_name,severity\n" + "2026-03-10T10:00:30Z,2026-03-10T10:01:00Z," + "2026-03-10T10:00:00Z,high_error_rate,medium\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError) as excinfo: + load_alert_table(path) + + message = str(excinfo.value) + assert "Window end must be after window start" in message + assert "1 row(s)" in message + + +def test_load_alert_table_rejects_alert_time_outside_window(tmp_path) -> None: + path = tmp_path / "alerts.csv" + path.write_text( + "alert_time,window_start,window_end,rule_name,severity\n" + "2026-03-10T10:02:00Z,2026-03-10T10:00:00Z," + "2026-03-10T10:01:00Z,high_error_rate,medium\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError) as excinfo: + load_alert_table(path) + + message = str(excinfo.value) + assert "Alert time must fall within window bounds" in message + assert "1 row(s)" in message + + def test_load_alert_table_rejects_missing_rule_name(tmp_path) -> None: path = tmp_path / "alerts.csv" path.write_text(