diff --git a/src/fenris/projection.py b/src/fenris/projection.py index d874f29..6cd0944 100644 --- a/src/fenris/projection.py +++ b/src/fenris/projection.py @@ -92,6 +92,7 @@ class ProjectionResult: staleness_fact: Optional[str] degraded_identity_fact: Optional[str] zero_rate_fact: Optional[str] + qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress # --------------------------------------------------------------------------- @@ -547,6 +548,16 @@ def compute_projection(conn, clock_now): days_below_coverage = sum(1 for d in segment_days if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0) qualifying = total_days_count - days_below_coverage + + # Issue #77: Show honest qualifying-day progress + if total_days_count >= WARMING_MIN_DAYS: + # After warm-up, show qualifying day details for transparency + qualifying_days_progress = "%d of %d qualifying days" % (qualifying, total_days_count) + if days_below_coverage > 0: + qualifying_days_progress += " (%d below coverage)" % days_below_coverage + else: + qualifying_days_progress = None + if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE: warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS) facts.append(warming_fact) @@ -605,4 +616,5 @@ def compute_projection(conn, clock_now): staleness_fact=staleness_fact, degraded_identity_fact=degraded_identity_fact, zero_rate_fact=zero_rate_fact, + qualifying_days_progress=qualifying_days_progress, ) diff --git a/src/fenris/status.py b/src/fenris/status.py index e406650..501473d 100644 --- a/src/fenris/status.py +++ b/src/fenris/status.py @@ -379,8 +379,14 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any], # --- Confidence state + contributing facts (§6.7, §6.11) --- state_label = proj.confidence_state.value - if proj.contributing_facts: - facts_str = " · ".join(proj.contributing_facts) + facts_list = list(proj.contributing_facts) if proj.contributing_facts else [] + + # Issue #77: Show qualifying day progress for honesty + if proj.qualifying_days_progress: + facts_list.insert(0, proj.qualifying_days_progress) + + if facts_list: + facts_str = " · ".join(facts_list) lines.append("%s evidence · %s" % (state_label, facts_str)) else: lines.append("%s evidence" % state_label) diff --git a/src/fenris/tui.py b/src/fenris/tui.py index 93b07ad..06adc21 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -1197,6 +1197,11 @@ class FenrisTuiApp(App): ConfidenceState.UNSUPPORTED: "red", }[proj.confidence_state] facts = " · ".join(proj.contributing_facts[:3]) if proj.contributing_facts else "no facts" + + # Issue #77: Show qualifying day progress for honesty + if proj.qualifying_days_progress: + facts = proj.qualifying_days_progress + " · " + facts + return "[bold]Projection confidence: [%s]%s[/%s][/bold]\n %s" % ( color, proj.confidence_state.value, diff --git a/tests/test_issue_77.py b/tests/test_issue_77.py new file mode 100644 index 0000000..f18945e --- /dev/null +++ b/tests/test_issue_77.py @@ -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