333 lines
15 KiB
Python
333 lines
15 KiB
Python
"""Complete observation day gate tests (issue #94).
|
|
|
|
Verifies that the endurance projection is withheld until at least one
|
|
complete local calendar day has been observed within a monitoring period.
|
|
|
|
Seams:
|
|
- compute_projection() → gate check via local_days table
|
|
- ProjectionResult.contributing_facts → "waiting for a full local observation day"
|
|
|
|
Acceptance criteria:
|
|
- Gate-1: No complete local day → UNSUPPORTED with waiting fact
|
|
- Gate-2: One complete local day → Limited confidence (if other conditions met)
|
|
- Gate-3: Partial days don't satisfy the gate
|
|
- Gate-4: CLI and TUI share the same gate via compute_projection()
|
|
"""
|
|
import sqlite3
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import sys
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
from fenris.store import init_store
|
|
from fenris.monitoring_periods import ensure_period_open
|
|
from fenris.projection import (
|
|
compute_projection, ConfidenceState, BaselineTier,
|
|
WARMING_COVERAGE_FLOOR,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path):
|
|
conn = init_store(tmp_path / "test.db")
|
|
yield conn
|
|
conn.close()
|
|
|
|
|
|
def _clock(year=2026, month=9, day=30, hour=12):
|
|
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def _insert_baseline(conn, tbw_tb=1.0, verified=True):
|
|
conn.execute(
|
|
"INSERT INTO endurance_baseline "
|
|
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
|
|
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01",
|
|
"Samsung SSD 970 EVO Plus 1TB", 1024000000000,
|
|
"machine_match" if verified else None, verified,
|
|
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00"):
|
|
conn.execute(
|
|
"INSERT INTO controller_segments "
|
|
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(opened_at, "nqn.test", False, "nqn.test", "SN123",
|
|
"Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
|
|
conn.execute(
|
|
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
|
|
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _insert_sample(conn, ts, pu=5):
|
|
conn.execute(
|
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
|
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(ts, "/dev/nvme0n1", 1000000, 500000, pu, 512000000000, 256000000000, 8765),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
|
utc_start=None, utc_end=None, bw=1024*1024*100,
|
|
br=0, coverage=0.95, samples=24, complete=True):
|
|
"""Insert a local_days row for testing the gate."""
|
|
if utc_start is None:
|
|
utc_start = local_date + "T00:00:00+00:00"
|
|
if utc_end is None:
|
|
# Next day
|
|
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
|
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
|
conn.execute(
|
|
"INSERT INTO local_days "
|
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
|
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
|
bw, br, coverage, samples, complete),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gate-1: No complete local day → UNSUPPORTED with waiting fact
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGateNoCompleteDay:
|
|
"""Projection is unavailable before any complete local observation day."""
|
|
|
|
def test_no_local_days_unsupported(self, store):
|
|
"""With no local_days entries, projection is UNSUPPORTED."""
|
|
_insert_baseline(store)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
# 14 days of UTC data — enough for normal projection, but no local_days
|
|
for i in range(14):
|
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
_insert_day(store, d, bw=1024*1024*100)
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
result = compute_projection(store, _clock())
|
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
|
assert any("full local observation day" in f for f in result.contributing_facts)
|
|
assert result.headline_remaining_seconds is None
|
|
|
|
def test_only_partial_local_days_unsupported(self, store):
|
|
"""Partial (incomplete) local days don't satisfy the gate."""
|
|
_insert_baseline(store)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
for i in range(14):
|
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
_insert_day(store, d, bw=1024*1024*100)
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
# Insert only incomplete local days
|
|
for i in range(5):
|
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
_insert_local_day(store, d, complete=False, coverage=0.3)
|
|
result = compute_projection(store, _clock())
|
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
|
assert any("full local observation day" in f for f in result.contributing_facts)
|
|
|
|
def test_gate_before_warming_check(self, store):
|
|
"""Gate fires even when warming would also block — gate has precedence."""
|
|
_insert_baseline(store)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
# Only 3 days of data (below warming threshold)
|
|
for i in range(3):
|
|
d = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
_insert_day(store, d, bw=1024*1024*100)
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
# Insert one complete local day — but still below warming
|
|
_insert_local_day(store, "2026-09-29", complete=True)
|
|
result = compute_projection(store, _clock())
|
|
# Gate is satisfied (one complete day), but warming blocks Supported
|
|
# The key assertion: gate message should NOT appear when gate IS met
|
|
assert not any("full local observation day" in f for f in result.contributing_facts)
|
|
|
|
def test_complete_legacy_day_without_trusted_activity_does_not_open_gate(
|
|
self, store,
|
|
):
|
|
"""A complete flag cannot make unavailable local activity qualify."""
|
|
_insert_baseline(store)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
for i in range(3):
|
|
day = (datetime(2026, 9, 27) + 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)
|
|
_insert_local_day(store, "2026-09-29", complete=True)
|
|
store.execute(
|
|
"UPDATE local_days SET activity_precision = 'measured', activity_intervals = 0 "
|
|
"WHERE local_date = '2026-09-29'"
|
|
)
|
|
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_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)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
for i in range(3):
|
|
day = (datetime(2026, 9, 27) + 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)
|
|
_insert_local_day(store, "2026-09-29", complete=True)
|
|
store.execute(
|
|
"UPDATE local_days SET activity_incomplete = 1 "
|
|
"WHERE local_date = '2026-09-29'"
|
|
)
|
|
|
|
result = compute_projection(store, _clock())
|
|
|
|
assert not any("full local observation day" in fact
|
|
for fact in result.contributing_facts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gate-2: One complete local day → Limited confidence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGateOneCompleteDay:
|
|
"""After one complete local day, projection can proceed with Limited confidence."""
|
|
|
|
def test_one_complete_day_allows_projection(self, store):
|
|
"""With one complete local day and valid baseline/rate, projection is Limited."""
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
# 14 days of UTC data
|
|
for i in range(14):
|
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
_insert_day(store, d, bw=1024*1024*100)
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
# One complete local day
|
|
_insert_local_day(store, "2026-09-29", complete=True)
|
|
result = compute_projection(store, _clock())
|
|
# Gate satisfied — no "waiting" fact
|
|
assert not any("full local observation day" in f for f in result.contributing_facts)
|
|
# With only 14 days and other Limited factors, should be Limited or Supported
|
|
assert result.confidence_state in (ConfidenceState.LIMITED, ConfidenceState.SUPPORTED)
|
|
# Headline should exist (rate > 0, baseline exists)
|
|
assert result.headline_remaining_seconds is not None
|
|
|
|
def test_gate_fact_absent_when_satisfied(self, store):
|
|
"""The 'waiting for full day' fact does not appear when gate is met."""
|
|
_insert_baseline(store)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
for i in range(20):
|
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
_insert_day(store, d, bw=1024*1024*100)
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
_insert_local_day(store, "2026-09-29", complete=True)
|
|
result = compute_projection(store, _clock())
|
|
assert not any("full local observation day" in f for f in result.contributing_facts)
|
|
assert result.confidence_state != ConfidenceState.UNSUPPORTED
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gate-3: Partial first day doesn't satisfy the gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGatePartialFirstDay:
|
|
"""Starting monitoring at noon means the partial first day doesn't count."""
|
|
|
|
def test_partial_first_day_not_enough(self, store):
|
|
"""A single incomplete local day (started at noon) doesn't open the gate."""
|
|
_insert_baseline(store)
|
|
_insert_segment(store, opened_at="2026-09-29T12:00:00+00:00")
|
|
_open_period(store, start="2026-09-29T12:00:00+00:00")
|
|
# Only Sep 29 (partial) and Sep 30 (today, partial)
|
|
_insert_day(store, "2026-09-29", bw=1024*1024*100, coverage=0.5, samples=12)
|
|
_insert_day(store, "2026-09-30", bw=1024*1024*100, coverage=0.5, samples=12)
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
# Only partial local days
|
|
_insert_local_day(store, "2026-09-29", complete=False, coverage=0.5)
|
|
_insert_local_day(store, "2026-09-30", complete=False, coverage=0.5)
|
|
result = compute_projection(store, _clock())
|
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
|
assert any("full local observation day" in f for f in result.contributing_facts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gate-4: Multiple complete days also satisfy the gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGateMultipleCompleteDays:
|
|
"""Multiple complete local days satisfy the gate."""
|
|
|
|
def test_multiple_complete_days_satisfy_gate(self, store):
|
|
"""Several complete local days open the gate."""
|
|
_insert_baseline(store)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
for i in range(14):
|
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
_insert_day(store, d, bw=1024*1024*100)
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
# 7 complete local days
|
|
for i in range(7):
|
|
d = (datetime(2026, 9, 23) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
_insert_local_day(store, d, complete=True)
|
|
result = compute_projection(store, _clock())
|
|
assert not any("full local observation day" in f for f in result.contributing_facts)
|
|
assert result.headline_remaining_seconds is not None
|