|
|
|
@@ -0,0 +1,258 @@
|
|
|
|
|
"""Tests for issue #77: Show honest qualifying-day progress and confidence.
|
|
|
|
|
|
|
|
|
|
These tests verify that:
|
|
|
|
|
1. Warming progress shows detailed qualifying day breakdown
|
|
|
|
|
2. Confidence state accurately reflects qualifying day progress
|
|
|
|
|
3. Edge cases like zero-delta intervals and unknown daily shares are handled
|
|
|
|
|
4. qualifying_days_progress shows honest qualifying day count
|
|
|
|
|
"""
|
|
|
|
|
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, close_period
|
|
|
|
|
from fenris.projection import (
|
|
|
|
|
compute_projection, ConfidenceState, BaselineTier,
|
|
|
|
|
WARMING_MIN_DAYS, WARMING_COVERAGE_FLOOR, WARMING_MAX_LOW_COVERAGE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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, model="Samsung SSD 970 EVO Plus 1TB"):
|
|
|
|
|
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", model, 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",
|
|
|
|
|
identity_key="nqn.test", degraded=False,
|
|
|
|
|
mn="Samsung SSD 970 EVO Plus 1TB"):
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO controller_segments "
|
|
|
|
|
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
|
|
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
|
|
|
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "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 _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
|
|
|
|
# Convert string to datetime if needed
|
|
|
|
|
if isinstance(start, str):
|
|
|
|
|
start = datetime.fromisoformat(start)
|
|
|
|
|
ensure_period_open(conn, start)
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestHonestQualifyingProgress:
|
|
|
|
|
"""Issue #77: Show honest qualifying-day progress and confidence."""
|
|
|
|
|
|
|
|
|
|
def test_warming_shows_qualifying_vs_nonqualifying(self, store):
|
|
|
|
|
"""Warming progress shows both qualifying and non-qualifying days."""
|
|
|
|
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
|
|
|
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
|
|
|
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
|
|
|
|
bw = 100 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
# 12 qualifying days + 2 non-qualifying (low coverage)
|
|
|
|
|
for i in range(14):
|
|
|
|
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
|
|
|
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
|
|
|
|
|
_insert_day(store, d, bw=bw, coverage=cov)
|
|
|
|
|
|
|
|
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
|
|
|
result = compute_projection(store, _clock())
|
|
|
|
|
|
|
|
|
|
# After warming, qualifying_days_progress shows honest count
|
|
|
|
|
assert result.warming_fact is None # Not warming (14 total, 2 below <= WARMING_MAX_LOW_COVERAGE)
|
|
|
|
|
assert result.qualifying_days_progress is not None
|
|
|
|
|
assert "12 of 14 qualifying days" in result.qualifying_days_progress
|
|
|
|
|
assert "2 below coverage" in result.qualifying_days_progress
|
|
|
|
|
|
|
|
|
|
def test_warming_progress_includes_nonqualifying_reason(self, store):
|
|
|
|
|
"""Warming progress indicates why days don't qualify."""
|
|
|
|
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
|
|
|
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
|
|
|
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
|
|
|
|
bw = 100 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
# 13 qualifying days + 1 non-qualifying (zero samples)
|
|
|
|
|
for i in range(14):
|
|
|
|
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
|
|
|
samples = 0 if i == 0 else 24 # First day has no samples
|
|
|
|
|
_insert_day(store, d, bw=bw, samples=samples)
|
|
|
|
|
|
|
|
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
|
|
|
result = compute_projection(store, _clock())
|
|
|
|
|
|
|
|
|
|
# After warming, qualifying_days_progress shows honest count
|
|
|
|
|
assert result.warming_fact is None # Not warming (14 total, 1 below <= WARMING_MAX_LOW_COVERAGE)
|
|
|
|
|
assert result.qualifying_days_progress is not None
|
|
|
|
|
assert "13 of 14 qualifying days" in result.qualifying_days_progress
|
|
|
|
|
assert "1 below coverage" in result.qualifying_days_progress
|
|
|
|
|
|
|
|
|
|
def test_confidence_updates_as_qualifying_days_increase(self, store):
|
|
|
|
|
"""Confidence state reflects actual qualifying day count."""
|
|
|
|
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
|
|
|
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
|
|
|
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
|
|
|
|
bw = 100 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
# Start with 10 days (below minimum)
|
|
|
|
|
for i in range(10):
|
|
|
|
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
|
|
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
|
|
|
|
|
|
|
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
|
|
|
result = compute_projection(store, _clock())
|
|
|
|
|
|
|
|
|
|
# Should be warming
|
|
|
|
|
assert result.warming_fact is not None
|
|
|
|
|
assert result.confidence_state == ConfidenceState.LIMITED
|
|
|
|
|
|
|
|
|
|
def test_zero_rate_with_qualifying_days(self, store):
|
|
|
|
|
"""Zero rate with qualifying days shows honest state."""
|
|
|
|
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
|
|
|
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
|
|
|
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
|
|
|
|
|
|
|
|
|
# 14 days with zero bytes written
|
|
|
|
|
for i in range(14):
|
|
|
|
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
|
|
|
_insert_day(store, d, bw=0, coverage=0.95)
|
|
|
|
|
|
|
|
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
|
|
|
result = compute_projection(store, _clock())
|
|
|
|
|
|
|
|
|
|
# Zero rate should be UNSUPPORTED
|
|
|
|
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
|
|
|
|
assert result.zero_rate_fact is not None
|
|
|
|
|
assert "no finite projection" in result.zero_rate_fact
|
|
|
|
|
|
|
|
|
|
def test_qualifying_days_excludes_low_coverage(self, store):
|
|
|
|
|
"""Days below 50% coverage don't count as qualifying."""
|
|
|
|
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
|
|
|
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
|
|
|
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
|
|
|
|
bw = 100 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
# 14 days total, but 3 have low coverage
|
|
|
|
|
for i in range(14):
|
|
|
|
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
|
|
|
cov = 0.30 if i < 3 else 0.95
|
|
|
|
|
_insert_day(store, d, bw=bw, coverage=cov)
|
|
|
|
|
|
|
|
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
|
|
|
result = compute_projection(store, _clock())
|
|
|
|
|
|
|
|
|
|
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
|
|
|
|
|
assert result.warming_fact is not None
|
|
|
|
|
assert "warming up" in result.warming_fact.lower()
|
|
|
|
|
|
|
|
|
|
def test_qualifying_days_excludes_zero_samples(self, store):
|
|
|
|
|
"""Days with zero samples don't count as qualifying."""
|
|
|
|
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
|
|
|
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
|
|
|
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
|
|
|
|
bw = 100 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
# 14 days total, but 3 have zero samples
|
|
|
|
|
for i in range(14):
|
|
|
|
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
|
|
|
samples = 0 if i < 3 else 24
|
|
|
|
|
_insert_day(store, d, bw=bw, samples=samples)
|
|
|
|
|
|
|
|
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
|
|
|
result = compute_projection(store, _clock())
|
|
|
|
|
|
|
|
|
|
# Should still be warming
|
|
|
|
|
assert result.warming_fact is not None
|
|
|
|
|
assert "warming up" in result.warming_fact.lower()
|
|
|
|
|
|
|
|
|
|
def test_qualifying_days_progress_after_warming(self, store):
|
|
|
|
|
"""After warming, qualifying_days_progress shows honest count."""
|
|
|
|
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
|
|
|
|
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
|
|
|
|
|
_open_period(store, start="2026-09-17T00:00:00+00:00")
|
|
|
|
|
bw = 100 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
# 14 total days, 12 qualifying (2 below coverage)
|
|
|
|
|
for i in range(14):
|
|
|
|
|
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
|
|
|
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
|
|
|
|
|
_insert_day(store, d, bw=bw, coverage=cov)
|
|
|
|
|
|
|
|
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
|
|
|
result = compute_projection(store, _clock())
|
|
|
|
|
|
|
|
|
|
# Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE)
|
|
|
|
|
assert result.warming_fact is None
|
|
|
|
|
# But qualifying_days_progress should show the honest count
|
|
|
|
|
assert result.qualifying_days_progress is not None
|
|
|
|
|
assert "12 of 14 qualifying days" in result.qualifying_days_progress
|
|
|
|
|
assert "2 below coverage" in result.qualifying_days_progress
|
|
|
|
|
|
|
|
|
|
def test_qualifying_days_progress_all_qualifying(self, store):
|
|
|
|
|
"""When all days qualify, qualifying_days_progress shows 100%."""
|
|
|
|
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
|
|
|
|
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
|
|
|
|
|
_open_period(store, start="2026-09-17T00:00:00+00:00")
|
|
|
|
|
bw = 100 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
# 14 days, all qualifying
|
|
|
|
|
for i in range(14):
|
|
|
|
|
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
|
|
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
|
|
|
|
|
|
|
|
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
|
|
|
|
result = compute_projection(store, _clock())
|
|
|
|
|
|
|
|
|
|
# Should not be warming
|
|
|
|
|
assert result.warming_fact is None
|
|
|
|
|
# qualifying_days_progress should show all qualifying
|
|
|
|
|
assert result.qualifying_days_progress is not None
|
|
|
|
|
assert "14 of 14 qualifying days" in result.qualifying_days_progress
|
|
|
|
|
# Should not show "below coverage" since all qualify
|
|
|
|
|
assert "below coverage" not in result.qualifying_days_progress
|