fix: validate complete-day monitoring coverage

This commit is contained in:
xavierk
2026-09-29 03:34:17 +05:30
parent d045e88043
commit e22b99442e
7 changed files with 130 additions and 65 deletions
+22 -31
View File
@@ -33,6 +33,11 @@ from itertools import pairwise
from typing import Any from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from .monitoring_periods import (
as_utc_datetime,
interval_within_one_monitoring_period,
)
@dataclass(frozen=True) @dataclass(frozen=True)
class LocalDaySummary: class LocalDaySummary:
@@ -308,27 +313,13 @@ def persist_local_day(
return created 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( def _same_monitoring_period(
conn: sqlite3.Connection, conn: sqlite3.Connection,
start: datetime, start: datetime,
end: datetime, end: datetime,
) -> bool: ) -> bool:
"""Return whether one recorded monitoring period contains the interval.""" """Return whether one recorded monitoring period contains the interval."""
for started_at, ended_at in conn.execute( return interval_within_one_monitoring_period(conn, start, end)
"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
def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int: 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 = {} segment_windows = {}
if {"monitoring_periods", "controller_segments"}.issubset(tables): if {"monitoring_periods", "controller_segments"}.issubset(tables):
periods = [ 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( for start, end in conn.execute(
"SELECT started_at, ended_at FROM monitoring_periods" "SELECT started_at, ended_at FROM monitoring_periods"
) )
] ]
segment_boundaries = [ segment_boundaries = [
(segment_id, _utc_datetime(opened_at)) (segment_id, as_utc_datetime(opened_at))
for segment_id, opened_at in conn.execute( for segment_id, opened_at in conn.execute(
"SELECT id, opened_at FROM controller_segments ORDER BY opened_at, id" "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 = [ 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], "bytes_written": row[2], "bytes_read": row[3],
"segment_id": row[4], "local_tz": row[5], "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], "id": row[0],
"local_date": row[1], "local_date": row[1],
"tz_name": row[2], "tz_name": row[2],
"start": _utc_datetime(row[3]), "start": as_utc_datetime(row[3]),
"end": _utc_datetime(row[4]), "end": as_utc_datetime(row[4]),
"precision": row[6], "precision": row[6],
} }
for row in recorded_days 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, local_day_id, local_date, tz_name, utc_start, utc_end,
prior_incomplete, prior_incomplete,
) = row ) = row
start = _utc_datetime(utc_start) start = as_utc_datetime(utc_start)
end = _utc_datetime(utc_end) end = as_utc_datetime(utc_end)
matching = matching_by_day[local_day_id] matching = matching_by_day[local_day_id]
if not matching: if not matching:
@@ -595,8 +586,8 @@ def _record_reconstructed_shared_intervals(
day_rows = [ day_rows = [
{ {
"id": row[0], "local_date": row[1], "tz_name": row[2], "id": row[0], "local_date": row[1], "tz_name": row[2],
"utc_start": _utc_datetime(row[3]), "utc_start": as_utc_datetime(row[3]),
"utc_end": _utc_datetime(row[4]), "utc_end": as_utc_datetime(row[4]),
"precision": row[6], "precision": row[6],
} }
for row in recorded_days for row in recorded_days
@@ -685,7 +676,7 @@ def _coarse_local_day_activity(
full_hours: dict[datetime, tuple] = {} full_hours: dict[datetime, tuple] = {}
boundary_hour_found = False boundary_hour_found = False
other_day_bounds = [ 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( for row in conn.execute(
"SELECT utc_start, utc_end FROM local_days WHERE id != ?", "SELECT utc_start, utc_end FROM local_days WHERE id != ?",
(local_day_id,), (local_day_id,),
@@ -698,7 +689,7 @@ def _coarse_local_day_activity(
"FROM hour_observations WHERE hour >= ? AND hour < ? ORDER BY hour", "FROM hour_observations WHERE hour >= ? AND hour < ? ORDER BY hour",
(first_hour.isoformat(), day_end.isoformat()), (first_hour.isoformat(), day_end.isoformat()),
): ):
hour_start = _utc_datetime(row[0]) hour_start = as_utc_datetime(row[0])
hour_end = hour_start + hour hour_end = hour_start + hour
if hour_start >= day_end or hour_end <= day_start: if hour_start >= day_end or hour_end <= day_start:
continue continue
@@ -856,8 +847,8 @@ def record_local_activity_interval(
both samples share one monitoring period, and the interval stays inside one both samples share one monitoring period, and the interval stays inside one
recorded local date. Every other valid difference remains one unallocated row. recorded local date. Every other valid difference remains one unallocated row.
""" """
start = _utc_datetime(previous["ts"]) start = as_utc_datetime(previous["ts"])
end = _utc_datetime(current["ts"]) end = as_utc_datetime(current["ts"])
if end <= start: if end <= start:
return None return None
@@ -1004,12 +995,12 @@ def mark_local_activity_gap(
previous: dict | None = None, previous: dict | None = None,
) -> None: ) -> None:
"""Mark dates around a controller boundary as missing local activity.""" """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() current_date = current.astimezone(ZoneInfo(current_tz)).date().isoformat()
_upsert_activity_day(conn, current_date, current_tz, incomplete=True) _upsert_activity_day(conn, current_date, current_tz, incomplete=True)
if previous is None or not previous.get("local_tz"): if previous is None or not previous.get("local_tz"):
return return
previous_at = _utc_datetime(previous["ts"]) previous_at = as_utc_datetime(previous["ts"])
previous_tz = previous["local_tz"] previous_tz = previous["local_tz"]
previous_date = previous_at.astimezone(ZoneInfo(previous_tz)).date().isoformat() previous_date = previous_at.astimezone(ZoneInfo(previous_tz)).date().isoformat()
_upsert_activity_day(conn, previous_date, previous_tz, incomplete=True) _upsert_activity_day(conn, previous_date, previous_tz, incomplete=True)
@@ -1127,7 +1118,7 @@ def query_local_day_summary(
== recorded_date == recorded_date
) )
day_seconds = int( 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 interval_seconds = activity_seconds or 0
full_activity_day = interval_seconds >= day_seconds or ( full_activity_day = interval_seconds >= day_seconds or (
+41 -1
View File
@@ -9,7 +9,7 @@ Key contracts:
- End causes: user_disabled, migrated, unknown_gap - End causes: user_disabled, migrated, unknown_gap
""" """
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime, timezone
def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None: 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 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( def wall_clock_in_periods(
conn: sqlite3.Connection, conn: sqlite3.Connection,
start: datetime, start: datetime,
+9 -10
View File
@@ -28,6 +28,7 @@ from datetime import datetime, timedelta, timezone
from enum import Enum from enum import Enum
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from .monitoring_periods import interval_within_one_monitoring_period
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants (spec §6) # Constants (spec §6)
@@ -494,18 +495,16 @@ def _has_complete_local_day(conn):
within a monitoring period that has usable observation evidence. within a monitoring period that has usable observation evidence.
This is the prerequisite for showing an endurance outlook. This is the prerequisite for showing an endurance outlook.
""" """
return _count_complete_local_days(conn) > 0 local_days = conn.execute(
"SELECT utc_start, utc_end FROM local_days "
def _count_complete_local_days(conn):
"""Count the number of complete local observation days."""
row = conn.execute(
"SELECT COUNT(*) FROM local_days "
"WHERE complete = 1 " "WHERE complete = 1 "
"AND activity_precision IN ('measured', 'coarse') " "AND activity_precision IN ('measured', 'coarse') "
"AND activity_intervals > 0" "AND activity_intervals > 0 ORDER BY id"
).fetchone() ).fetchall()
return row[0] if row else 0 return any(
interval_within_one_monitoring_period(conn, utc_start, utc_end)
for utc_start, utc_end in local_days
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+10 -21
View File
@@ -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. evidence, publication recovery, or the newest sample's successor-anchor role.
""" """
import sqlite3 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 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: def _parse_sample_time(value: str) -> datetime | None:
"""Parse old timestamps conservatively; malformed legacy values stay.""" """Parse old timestamps conservatively; malformed legacy values stay."""
try: try:
parsed = datetime.fromisoformat(value) parsed = datetime.fromisoformat(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
return _utc_datetime(parsed) return as_utc_datetime(parsed)
def _sample_rows(conn: sqlite3.Connection) -> list[tuple]: def _sample_rows(conn: sqlite3.Connection) -> list[tuple]:
@@ -149,16 +147,7 @@ def _monitoring_period_covers(
end: datetime, end: datetime,
) -> bool: ) -> bool:
"""Require continuous monitoring before a local-day total replaces detail.""" """Require continuous monitoring before a local-day total replaces detail."""
for started_at, ended_at in conn.execute( return interval_within_one_monitoring_period(conn, start, end)
"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
def _has_local_replacement( def _has_local_replacement(
@@ -312,7 +301,7 @@ def needs_boundary_anchor(
) -> bool: ) -> bool:
"""Return whether an old sample still carries unreplaced evidence.""" """Return whether an old sample still carries unreplaced evidence."""
sample_time = _parse_sample_time(sample_ts) 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: if sample_time is None:
return True return True
if sample_time >= cutoff: if sample_time >= cutoff:
@@ -341,7 +330,7 @@ def prune_old_samples(
neighbour interval, lacks UTC or local-day replacement evidence, or has a neighbour interval, lacks UTC or local-day replacement evidence, or has a
timestamp that cannot be safely interpreted. 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 owns_transaction = not conn.in_transaction
savepoint = "fenris_sample_retention" savepoint = "fenris_sample_retention"
if owns_transaction: if owns_transaction:
@@ -187,6 +187,39 @@ class TestGateNoCompleteDay:
assert any("full local observation day" in fact assert any("full local observation day" in fact
for fact in result.contributing_facts) 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): def test_partial_but_trusted_local_activity_opens_gate(self, store):
"""Known local intervals can coexist with an incomplete day total.""" """Known local intervals can coexist with an incomplete day total."""
_insert_baseline(store) _insert_baseline(store)
+4
View File
@@ -211,6 +211,8 @@ class TestStalenessFact:
_insert_baseline(conn) _insert_baseline(conn)
base = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) base = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
ensure_period_open(conn, base)
conn.commit()
for i in range(14): for i in range(14):
day = (base + timedelta(days=i)).strftime("%Y-%m-%d") day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, day, bw=10*1024*1024*1024) _insert_day(conn, day, bw=10*1024*1024*1024)
@@ -234,6 +236,8 @@ class TestStalenessFact:
_insert_baseline(conn) _insert_baseline(conn)
base = datetime(2026, 9, 18, 12, 0, 0, tzinfo=timezone.utc) base = datetime(2026, 9, 18, 12, 0, 0, tzinfo=timezone.utc)
ensure_period_open(conn, base)
conn.commit()
for i in range(14): for i in range(14):
day = (base + timedelta(days=i)).strftime("%Y-%m-%d") day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, day, bw=10*1024*1024*1024) _insert_day(conn, day, bw=10*1024*1024*1024)
+11 -2
View File
@@ -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) 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, def _insert_baseline(conn, tbw_tb=1.0, verified=True,
model="Samsung SSD 970 EVO Plus 1TB"): model="Samsung SSD 970 EVO Plus 1TB"):
conn.execute( conn.execute(
@@ -1022,7 +1029,7 @@ class TestBarGraphTUI:
assert len(graph._day_data) == 90 assert len(graph._day_data) == 90
@pytest.mark.asyncio @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.""" """Readout shows selected day info."""
conn = init_store(tmp_path / "test.db") conn = init_store(tmp_path / "test.db")
_insert_segment(conn) _insert_segment(conn)
@@ -1034,6 +1041,7 @@ class TestBarGraphTUI:
_insert_sample(conn, "2026-09-30T10:00:00+00:00") _insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close() conn.close()
monkeypatch.setattr("fenris.tui.datetime", _FrozenTuiDateTime)
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v") await pilot.press("v", "v")
@@ -1056,7 +1064,7 @@ class TestBarGraphTUI:
assert "W 0.105 GB known" in readout assert "W 0.105 GB known" in readout
@pytest.mark.asyncio @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.""" """Enter drills into hourly view, Esc returns to daily."""
conn = init_store(tmp_path / "test.db") conn = init_store(tmp_path / "test.db")
_insert_segment(conn) _insert_segment(conn)
@@ -1073,6 +1081,7 @@ class TestBarGraphTUI:
_insert_sample(conn, "2026-09-30T10:00:00+00:00") _insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close() conn.close()
monkeypatch.setattr("fenris.tui.datetime", _FrozenTuiDateTime)
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v") await pilot.press("v", "v")