341 lines
15 KiB
Python
341 lines
15 KiB
Python
"""Projection core tests.
|
|
|
|
Covers acceptance criteria:
|
|
- PR-1: Exactly one projection from precedence-chosen baseline; PU context only
|
|
- PR-8: Confidence rule table holds verbatim; state + facts, never percentage
|
|
- PR-10: Implied baseline eligible only after >=2 PU increments
|
|
- PR-11: Zero rate renders fixed phrase; scenario range only spread
|
|
- PR-12: Contract hands over exactly: state, facts, headline, scenario, PU, disclosures
|
|
- PR-13: Baseline provenance and validation per register
|
|
- PR-17: Arithmetic exactly E_rated = TBW * 10^12, E_implied = 100*W/p, projected = max(E-W,0)/rate
|
|
"""
|
|
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, ScenarioRange,
|
|
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
|
|
YOUNG_REGIME_DAYS, DISCLOSURES,
|
|
)
|
|
|
|
|
|
@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",
|
|
source_url="https://example.com/spec", doc_rev="v1.0",
|
|
entry_date="2026-01-01", nominal_cap=1024000000000):
|
|
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, source_url, doc_rev, entry_date, model, nominal_cap,
|
|
"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"):
|
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
|
|
|
|
|
class TestPrecedence:
|
|
def test_no_baseline_unavailable(self, store):
|
|
_insert_segment(store)
|
|
_insert_day(store, "2026-09-28", bw=1024*1024*1000)
|
|
_open_period(store)
|
|
result = compute_projection(store, _clock())
|
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
|
assert result.baseline_tier == BaselineTier.NONE
|
|
assert result.headline_remaining_seconds is None
|
|
|
|
def test_verified_baseline_chosen(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_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)
|
|
result = compute_projection(store, _clock())
|
|
assert result.baseline_tier == BaselineTier.VERIFIED
|
|
assert "verified manufacturer TBW" in result.baseline_label
|
|
|
|
def test_pu_is_context_not_second_projection(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_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=10)
|
|
result = compute_projection(store, _clock())
|
|
assert result.pu_context_line.startswith("Percentage Used:")
|
|
assert "%" in result.pu_context_line
|
|
|
|
|
|
class TestConfidenceRuleTable:
|
|
def test_unavailable_no_baseline(self, store):
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
result = compute_projection(store, _clock())
|
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
|
assert any("no applicable endurance baseline" in f for f in result.contributing_facts)
|
|
|
|
def test_unavailable_zero_rate(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_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=0)
|
|
result = compute_projection(store, _clock())
|
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
|
assert any("no finite projection" in f for f in result.contributing_facts)
|
|
|
|
def test_limited_young_regime(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
for i in range(5):
|
|
d = (datetime(2026, 9, 25) + 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.LIMITED
|
|
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
|
|
|
def test_limited_degraded_identity(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_insert_segment(store, degraded=True)
|
|
_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)
|
|
result = compute_projection(store, _clock())
|
|
assert result.confidence_state == ConfidenceState.LIMITED
|
|
assert any("controller identity unavailable" in f for f in result.contributing_facts)
|
|
|
|
def test_state_plus_facts_never_percentage(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_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)
|
|
result = compute_projection(store, _clock())
|
|
assert result.confidence_state in ConfidenceState
|
|
for f in result.contributing_facts:
|
|
assert "%" not in f or "coverage" in f or "Percentage" in f
|
|
|
|
|
|
class TestImpliedBaseline:
|
|
def test_implied_not_chosen_with_verified(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_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)
|
|
result = compute_projection(store, _clock())
|
|
assert result.baseline_tier == BaselineTier.VERIFIED
|
|
|
|
|
|
class TestZeroRate:
|
|
def test_zero_rate_fixed_phrase(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_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=0)
|
|
result = compute_projection(store, _clock())
|
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
|
assert any("no finite projection from this history" in f for f in result.contributing_facts)
|
|
assert result.headline_remaining_seconds is None
|
|
|
|
def test_scenario_range_only_spread(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
for i in range(30):
|
|
d = (datetime(2026, 9, 1) + 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())
|
|
if result.scenario_range is not None:
|
|
assert isinstance(result.scenario_range, ScenarioRange)
|
|
assert hasattr(result.scenario_range, "rates")
|
|
|
|
|
|
class TestContractHandoff:
|
|
def test_contract_fields_present(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_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)
|
|
result = compute_projection(store, _clock())
|
|
assert isinstance(result.confidence_state, ConfidenceState)
|
|
assert isinstance(result.contributing_facts, list)
|
|
assert isinstance(result.pu_context_line, str)
|
|
assert isinstance(result.disclosure_text, list)
|
|
assert isinstance(result.baseline_tier, BaselineTier)
|
|
assert isinstance(result.baseline_label, str)
|
|
|
|
def test_recomputed_on_read(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_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)
|
|
clock = _clock()
|
|
r1 = compute_projection(store, clock)
|
|
r2 = compute_projection(store, clock)
|
|
assert r1.confidence_state == r2.confidence_state
|
|
assert r1.headline_remaining_seconds == r2.headline_remaining_seconds
|
|
|
|
def test_disclosures_present(self, store):
|
|
result = compute_projection(store, _clock())
|
|
assert len(result.disclosure_text) == 6
|
|
for d in DISCLOSURES:
|
|
assert d in result.disclosure_text
|
|
|
|
|
|
class TestBaselineProvenance:
|
|
def test_model_mismatch_unavailable(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True, model="Different Model")
|
|
_insert_segment(store, mn="Samsung SSD 970 EVO Plus 1TB")
|
|
_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)
|
|
result = compute_projection(store, _clock())
|
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
|
assert any("does not match" in f for f in result.contributing_facts)
|
|
assert result.baseline_tier == BaselineTier.NONE
|
|
|
|
|
|
class TestArithmetic:
|
|
def test_rated_tbw_conversion(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_insert_segment(store)
|
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
|
for i in range(30):
|
|
d = (datetime(2026, 9, 1) + 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())
|
|
if result.headline_remaining_seconds is not None:
|
|
E_rated = 1.0 * TBW_TO_BYTES
|
|
regime_bytes = 30 * 1024 * 1024 * 100
|
|
# Actual wall-clock: Sep 1 00:00 -> Sep 30 12:00 = 29.5 days
|
|
period_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
period_end = _clock()
|
|
actual_wc = int((period_end - period_start).total_seconds())
|
|
rate = regime_bytes / actual_wc
|
|
expected = max(E_rated - regime_bytes, 0) / rate
|
|
assert abs(result.headline_remaining_seconds - expected) < 1.0
|
|
|
|
def test_implied_baseline_formula(self, store):
|
|
W_t = 1024 * 1024 * 1000
|
|
p = 10
|
|
E_implied = 100 * W_t / p
|
|
assert E_implied == 100 * 1024 * 1024 * 1000 / 10
|
|
|
|
def test_projected_formula(self, store):
|
|
_insert_baseline(store, tbw_tb=2.0, verified=True)
|
|
_insert_segment(store)
|
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
|
for i in range(30):
|
|
d = (datetime(2026, 9, 1) + 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())
|
|
if result.headline_remaining_seconds is not None:
|
|
E_rated = 2.0 * TBW_TO_BYTES
|
|
regime_bytes = 30 * 1024 * 1024 * 100
|
|
period_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
period_end = _clock()
|
|
actual_wc = int((period_end - period_start).total_seconds())
|
|
rate = regime_bytes / actual_wc
|
|
expected = max(E_rated - regime_bytes, 0) / rate
|
|
assert abs(result.headline_remaining_seconds - expected) < 1.0
|
|
|
|
def test_wearing_rate_proportional(self, store):
|
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
|
_insert_segment(store)
|
|
_open_period(store)
|
|
for i in range(30):
|
|
d = (datetime(2026, 9, 1) + 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)
|
|
r_slow = compute_projection(store, _clock())
|
|
|
|
store.execute("DELETE FROM day_aggregates")
|
|
store.commit()
|
|
for i in range(30):
|
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
_insert_day(store, d, bw=2*1024*1024*100)
|
|
r_fast = compute_projection(store, _clock())
|
|
|
|
if r_slow.headline_remaining_seconds is not None and r_fast.headline_remaining_seconds is not None:
|
|
assert r_fast.headline_remaining_seconds < r_slow.headline_remaining_seconds
|