From e22b99442e76b57460948d74cdd21e81c8a89b66 Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 29 Sep 2026 03:34:17 +0530 Subject: [PATCH] fix: validate complete-day monitoring coverage --- src/fenris/local_day.py | 53 +++++++++------------ src/fenris/monitoring_periods.py | 42 +++++++++++++++- src/fenris/projection.py | 19 ++++---- src/fenris/pruning.py | 31 ++++-------- tests/test_complete_observation_day_gate.py | 33 +++++++++++++ tests/test_issue_72_edge_cases.py | 4 ++ tests/test_tui.py | 13 ++++- 7 files changed, 130 insertions(+), 65 deletions(-) diff --git a/src/fenris/local_day.py b/src/fenris/local_day.py index d9e67a5..481a798 100644 --- a/src/fenris/local_day.py +++ b/src/fenris/local_day.py @@ -33,6 +33,11 @@ from itertools import pairwise from typing import Any from zoneinfo import ZoneInfo +from .monitoring_periods import ( + as_utc_datetime, + interval_within_one_monitoring_period, +) + @dataclass(frozen=True) class LocalDaySummary: @@ -308,27 +313,13 @@ def persist_local_day( return created -def _utc_datetime(value: str | datetime) -> datetime: - parsed = value if isinstance(value, datetime) else datetime.fromisoformat(value) - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) - - def _same_monitoring_period( conn: sqlite3.Connection, start: datetime, end: datetime, ) -> bool: """Return whether one recorded monitoring period contains the interval.""" - for started_at, ended_at in conn.execute( - "SELECT started_at, ended_at FROM monitoring_periods" - ): - period_start = _utc_datetime(started_at) - period_end = _utc_datetime(ended_at) if ended_at else None - if period_start <= start and (period_end is None or end <= period_end): - return True - return False + return interval_within_one_monitoring_period(conn, start, end) def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int: @@ -365,13 +356,13 @@ def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int: segment_windows = {} if {"monitoring_periods", "controller_segments"}.issubset(tables): periods = [ - (_utc_datetime(start), _utc_datetime(end) if end else None) + (as_utc_datetime(start), as_utc_datetime(end) if end else None) for start, end in conn.execute( "SELECT started_at, ended_at FROM monitoring_periods" ) ] segment_boundaries = [ - (segment_id, _utc_datetime(opened_at)) + (segment_id, as_utc_datetime(opened_at)) for segment_id, opened_at in conn.execute( "SELECT id, opened_at FROM controller_segments ORDER BY opened_at, id" ) @@ -399,7 +390,7 @@ def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int: ) samples = [ { - "id": row[0], "ts": _utc_datetime(row[1]), + "id": row[0], "ts": as_utc_datetime(row[1]), "bytes_written": row[2], "bytes_read": row[3], "segment_id": row[4], "local_tz": row[5], } @@ -453,8 +444,8 @@ def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int: "id": row[0], "local_date": row[1], "tz_name": row[2], - "start": _utc_datetime(row[3]), - "end": _utc_datetime(row[4]), + "start": as_utc_datetime(row[3]), + "end": as_utc_datetime(row[4]), "precision": row[6], } for row in recorded_days @@ -482,8 +473,8 @@ def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int: local_day_id, local_date, tz_name, utc_start, utc_end, prior_incomplete, ) = row - start = _utc_datetime(utc_start) - end = _utc_datetime(utc_end) + start = as_utc_datetime(utc_start) + end = as_utc_datetime(utc_end) matching = matching_by_day[local_day_id] if not matching: @@ -595,8 +586,8 @@ def _record_reconstructed_shared_intervals( day_rows = [ { "id": row[0], "local_date": row[1], "tz_name": row[2], - "utc_start": _utc_datetime(row[3]), - "utc_end": _utc_datetime(row[4]), + "utc_start": as_utc_datetime(row[3]), + "utc_end": as_utc_datetime(row[4]), "precision": row[6], } for row in recorded_days @@ -685,7 +676,7 @@ def _coarse_local_day_activity( full_hours: dict[datetime, tuple] = {} boundary_hour_found = False other_day_bounds = [ - (_utc_datetime(row[0]), _utc_datetime(row[1])) + (as_utc_datetime(row[0]), as_utc_datetime(row[1])) for row in conn.execute( "SELECT utc_start, utc_end FROM local_days WHERE id != ?", (local_day_id,), @@ -698,7 +689,7 @@ def _coarse_local_day_activity( "FROM hour_observations WHERE hour >= ? AND hour < ? ORDER BY hour", (first_hour.isoformat(), day_end.isoformat()), ): - hour_start = _utc_datetime(row[0]) + hour_start = as_utc_datetime(row[0]) hour_end = hour_start + hour if hour_start >= day_end or hour_end <= day_start: continue @@ -856,8 +847,8 @@ def record_local_activity_interval( both samples share one monitoring period, and the interval stays inside one recorded local date. Every other valid difference remains one unallocated row. """ - start = _utc_datetime(previous["ts"]) - end = _utc_datetime(current["ts"]) + start = as_utc_datetime(previous["ts"]) + end = as_utc_datetime(current["ts"]) if end <= start: return None @@ -1004,12 +995,12 @@ def mark_local_activity_gap( previous: dict | None = None, ) -> None: """Mark dates around a controller boundary as missing local activity.""" - current = _utc_datetime(current_ts) + current = as_utc_datetime(current_ts) current_date = current.astimezone(ZoneInfo(current_tz)).date().isoformat() _upsert_activity_day(conn, current_date, current_tz, incomplete=True) if previous is None or not previous.get("local_tz"): return - previous_at = _utc_datetime(previous["ts"]) + previous_at = as_utc_datetime(previous["ts"]) previous_tz = previous["local_tz"] previous_date = previous_at.astimezone(ZoneInfo(previous_tz)).date().isoformat() _upsert_activity_day(conn, previous_date, previous_tz, incomplete=True) @@ -1127,7 +1118,7 @@ def query_local_day_summary( == recorded_date ) day_seconds = int( - (_utc_datetime(utc_end) - _utc_datetime(utc_start)).total_seconds() + (as_utc_datetime(utc_end) - as_utc_datetime(utc_start)).total_seconds() ) interval_seconds = activity_seconds or 0 full_activity_day = interval_seconds >= day_seconds or ( diff --git a/src/fenris/monitoring_periods.py b/src/fenris/monitoring_periods.py index 622e6f8..e8b9b23 100644 --- a/src/fenris/monitoring_periods.py +++ b/src/fenris/monitoring_periods.py @@ -9,7 +9,7 @@ Key contracts: - End causes: user_disabled, migrated, unknown_gap """ import sqlite3 -from datetime import datetime +from datetime import datetime, timezone def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None: @@ -82,6 +82,46 @@ def is_inside_period(conn: sqlite3.Connection, ts: datetime) -> bool: return cursor.fetchone() is not None +def interval_within_one_monitoring_period( + conn: sqlite3.Connection, + start: str | datetime, + end: str | datetime, +) -> bool: + """Return whether one monitoring period contains the full interval. + + Compare timestamps as UTC instants. Malformed timestamps fail closed. + """ + try: + interval_start = as_utc_datetime(start) + interval_end = as_utc_datetime(end) + except (TypeError, ValueError, OverflowError): + return False + if interval_end <= interval_start: + return False + + for started_at, ended_at in conn.execute( + "SELECT started_at, ended_at FROM monitoring_periods" + ): + try: + period_start = as_utc_datetime(started_at) + period_end = as_utc_datetime(ended_at) if ended_at is not None else None + except (TypeError, ValueError, OverflowError): + continue + if period_start <= interval_start and ( + period_end is None or interval_end <= period_end + ): + return True + return False + + +def as_utc_datetime(value: str | datetime) -> datetime: + """Parse a timestamp and normalize it to an aware UTC datetime.""" + parsed = value if isinstance(value, datetime) else datetime.fromisoformat(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def wall_clock_in_periods( conn: sqlite3.Connection, start: datetime, diff --git a/src/fenris/projection.py b/src/fenris/projection.py index 122671a..9e09da0 100644 --- a/src/fenris/projection.py +++ b/src/fenris/projection.py @@ -28,6 +28,7 @@ from datetime import datetime, timedelta, timezone from enum import Enum from typing import Any, Dict, List, Optional, Tuple +from .monitoring_periods import interval_within_one_monitoring_period # --------------------------------------------------------------------------- # Constants (spec ยง6) @@ -494,18 +495,16 @@ def _has_complete_local_day(conn): within a monitoring period that has usable observation evidence. This is the prerequisite for showing an endurance outlook. """ - return _count_complete_local_days(conn) > 0 - - -def _count_complete_local_days(conn): - """Count the number of complete local observation days.""" - row = conn.execute( - "SELECT COUNT(*) FROM local_days " + local_days = conn.execute( + "SELECT utc_start, utc_end FROM local_days " "WHERE complete = 1 " "AND activity_precision IN ('measured', 'coarse') " - "AND activity_intervals > 0" - ).fetchone() - return row[0] if row else 0 + "AND activity_intervals > 0 ORDER BY id" + ).fetchall() + return any( + interval_within_one_monitoring_period(conn, utc_start, utc_end) + for utc_start, utc_end in local_days + ) # --------------------------------------------------------------------------- diff --git a/src/fenris/pruning.py b/src/fenris/pruning.py index 0db762e..529c242 100644 --- a/src/fenris/pruning.py +++ b/src/fenris/pruning.py @@ -4,25 +4,23 @@ The 14-day cutoff never overrides local-day preservation, shared boundary evidence, publication recovery, or the newest sample's successor-anchor role. """ import sqlite3 -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timedelta + +from .monitoring_periods import ( + as_utc_datetime, + interval_within_one_monitoring_period, +) RAW_SAMPLE_RETENTION_DAYS = 14 -def _utc_datetime(value: datetime) -> datetime: - """Normalize a clock value to aware UTC.""" - if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc) - - def _parse_sample_time(value: str) -> datetime | None: """Parse old timestamps conservatively; malformed legacy values stay.""" try: parsed = datetime.fromisoformat(value) except (TypeError, ValueError): return None - return _utc_datetime(parsed) + return as_utc_datetime(parsed) def _sample_rows(conn: sqlite3.Connection) -> list[tuple]: @@ -149,16 +147,7 @@ def _monitoring_period_covers( end: datetime, ) -> bool: """Require continuous monitoring before a local-day total replaces detail.""" - for started_at, ended_at in conn.execute( - "SELECT started_at, ended_at FROM monitoring_periods" - ): - period_start = _parse_sample_time(started_at) - period_end = _parse_sample_time(ended_at) if ended_at is not None else None - if period_start is None or period_start > start: - continue - if ended_at is None or (period_end is not None and end <= period_end): - return True - return False + return interval_within_one_monitoring_period(conn, start, end) def _has_local_replacement( @@ -312,7 +301,7 @@ def needs_boundary_anchor( ) -> bool: """Return whether an old sample still carries unreplaced evidence.""" sample_time = _parse_sample_time(sample_ts) - cutoff = _utc_datetime(now) - timedelta(days=RAW_SAMPLE_RETENTION_DAYS) + cutoff = as_utc_datetime(now) - timedelta(days=RAW_SAMPLE_RETENTION_DAYS) if sample_time is None: return True if sample_time >= cutoff: @@ -341,7 +330,7 @@ def prune_old_samples( neighbour interval, lacks UTC or local-day replacement evidence, or has a timestamp that cannot be safely interpreted. """ - cutoff = _utc_datetime(now) - timedelta(days=retention_days) + cutoff = as_utc_datetime(now) - timedelta(days=retention_days) owns_transaction = not conn.in_transaction savepoint = "fenris_sample_retention" if owns_transaction: diff --git a/tests/test_complete_observation_day_gate.py b/tests/test_complete_observation_day_gate.py index 574d083..c541538 100644 --- a/tests/test_complete_observation_day_gate.py +++ b/tests/test_complete_observation_day_gate.py @@ -187,6 +187,39 @@ class TestGateNoCompleteDay: assert any("full local observation day" in fact for fact in result.contributing_facts) + def test_day_split_by_deliberate_pause_does_not_open_gate(self, store): + """A complete-looking summary cannot span separate monitoring periods.""" + _insert_baseline(store) + _insert_segment(store) + for i in range(20): + day = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(store, day, bw=1024 * 1024 * 100) + _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) + + store.executemany( + "INSERT INTO monitoring_periods (started_at, ended_at, end_cause) " + "VALUES (?, ?, ?)", + [ + ("2026-09-29T00:00:00+00:00", "2026-09-29T12:00:00+00:00", "user_disabled"), + ("2026-09-29T13:00:00+00:00", "2026-09-30T00:00:00+00:00", "user_disabled"), + ("2026-09-30T00:00:00+00:00", None, None), + ], + ) + store.commit() + _insert_local_day( + store, + "2026-09-29", + utc_start="2026-09-29T00:00:00+00:00", + utc_end="2026-09-30T00:00:00+00:00", + complete=True, + ) + + result = compute_projection(store, _clock()) + + assert result.confidence_state == ConfidenceState.UNSUPPORTED + assert any("full local observation day" in fact + for fact in result.contributing_facts) + def test_partial_but_trusted_local_activity_opens_gate(self, store): """Known local intervals can coexist with an incomplete day total.""" _insert_baseline(store) diff --git a/tests/test_issue_72_edge_cases.py b/tests/test_issue_72_edge_cases.py index 1a496bf..43319ad 100644 --- a/tests/test_issue_72_edge_cases.py +++ b/tests/test_issue_72_edge_cases.py @@ -211,6 +211,8 @@ class TestStalenessFact: _insert_baseline(conn) base = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) + ensure_period_open(conn, base) + conn.commit() for i in range(14): day = (base + timedelta(days=i)).strftime("%Y-%m-%d") _insert_day(conn, day, bw=10*1024*1024*1024) @@ -234,6 +236,8 @@ class TestStalenessFact: _insert_baseline(conn) base = datetime(2026, 9, 18, 12, 0, 0, tzinfo=timezone.utc) + ensure_period_open(conn, base) + conn.commit() for i in range(14): day = (base + timedelta(days=i)).strftime("%Y-%m-%d") _insert_day(conn, day, bw=10*1024*1024*1024) diff --git a/tests/test_tui.py b/tests/test_tui.py index 500787c..4770b0c 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -60,6 +60,13 @@ def _clock(year=2026, month=9, day=30, hour=12): return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc) +class _FrozenTuiDateTime(datetime): + @classmethod + def now(cls, tz=None): + instant = datetime(2026, 9, 28, 12, 0, 0, tzinfo=timezone.utc) + return instant.replace(tzinfo=None) if tz is None else instant.astimezone(tz) + + def _insert_baseline(conn, tbw_tb=1.0, verified=True, model="Samsung SSD 970 EVO Plus 1TB"): conn.execute( @@ -1022,7 +1029,7 @@ class TestBarGraphTUI: assert len(graph._day_data) == 90 @pytest.mark.asyncio - async def test_readout_updates_on_selection(self, tmp_path): + async def test_readout_updates_on_selection(self, tmp_path, monkeypatch): """Readout shows selected day info.""" conn = init_store(tmp_path / "test.db") _insert_segment(conn) @@ -1034,6 +1041,7 @@ class TestBarGraphTUI: _insert_sample(conn, "2026-09-30T10:00:00+00:00") conn.close() + monkeypatch.setattr("fenris.tui.datetime", _FrozenTuiDateTime) app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 24)) as pilot: await pilot.press("v", "v") @@ -1056,7 +1064,7 @@ class TestBarGraphTUI: assert "W 0.105 GB known" in readout @pytest.mark.asyncio - async def test_hourly_drill_down_and_back(self, tmp_path): + async def test_hourly_drill_down_and_back(self, tmp_path, monkeypatch): """Enter drills into hourly view, Esc returns to daily.""" conn = init_store(tmp_path / "test.db") _insert_segment(conn) @@ -1073,6 +1081,7 @@ class TestBarGraphTUI: _insert_sample(conn, "2026-09-30T10:00:00+00:00") conn.close() + monkeypatch.setattr("fenris.tui.datetime", _FrozenTuiDateTime) app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 24)) as pilot: await pilot.press("v", "v")