feat(projection): show staged projection with evidence ladder

Report a projection stage, an evidence ladder and the sustained-regime
write rate from compute_projection, per ADR 0012. A provisional lifespan
appears after 3 observed hours and carries the hours observed and a
short-horizon spread; the complete-local-day condition becomes a fact
instead of a gate, and the write rate replaces the lifespan when no
baseline applies. The TUI and CLI render the stage, ladder count and
full ladder, and drop their own two-sample pre-gates.
This commit is contained in:
xavierk
2026-10-05 19:57:46 +05:30
parent 96fd1702fa
commit e26a851115
8 changed files with 615 additions and 148 deletions
+22 -20
View File
@@ -1,16 +1,18 @@
"""Complete observation day gate tests (issue #94).
"""Complete observation day condition tests (issues #94, #106).
Verifies that the endurance projection is withheld until at least one
complete local calendar day has been observed within a monitoring period.
Originally a gate (issue #94); ADR 0012 turned it into a contributing
fact. The projection is no longer withheld until one complete local
calendar day has been observed within a monitoring period, but it says
so while that condition is unmet.
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-1: No complete local day → projection renders with waiting fact
- Gate-2: One complete local day → no waiting fact
- Gate-3: Partial days don't satisfy the condition
- Gate-4: CLI and TUI share the same gate via compute_projection()
"""
import sqlite3
@@ -111,14 +113,14 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
# ---------------------------------------------------------------------------
# Gate-1: No complete local day → UNSUPPORTED with waiting fact
# Gate-1: No complete local day → projection renders with waiting fact
# ---------------------------------------------------------------------------
class TestGateNoCompleteDay:
"""Projection is unavailable before any complete local observation day."""
"""Projection still renders before any complete local observation day."""
def test_no_local_days_unsupported(self, store):
"""With no local_days entries, projection is UNSUPPORTED."""
def test_no_local_days_states_waiting_fact(self, store):
"""With no local_days entries, the projection renders and says it is waiting."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
@@ -128,11 +130,11 @@ class TestGateNoCompleteDay:
_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 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
assert result.headline_remaining_seconds is not None
def test_only_partial_local_days_unsupported(self, store):
def test_only_partial_local_days_state_waiting_fact(self, store):
"""Partial (incomplete) local days don't satisfy the gate."""
_insert_baseline(store)
_insert_segment(store)
@@ -146,7 +148,7 @@ class TestGateNoCompleteDay:
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 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):
@@ -166,7 +168,7 @@ class TestGateNoCompleteDay:
# 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(
def test_complete_legacy_day_without_trusted_activity_keeps_waiting_fact(
self, store,
):
"""A complete flag cannot make unavailable local activity qualify."""
@@ -183,11 +185,11 @@ class TestGateNoCompleteDay:
"WHERE local_date = '2026-09-29'"
)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
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):
def test_day_split_by_deliberate_pause_keeps_waiting_fact(self, store):
"""A complete-looking summary cannot span separate monitoring periods."""
_insert_baseline(store)
_insert_segment(store)
@@ -216,7 +218,7 @@ class TestGateNoCompleteDay:
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.confidence_state != ConfidenceState.UNSUPPORTED
assert any("full local observation day" in fact
for fact in result.contributing_facts)
@@ -290,7 +292,7 @@ class TestGateOneCompleteDay:
class TestGatePartialFirstDay:
"""Starting monitoring at noon means the partial first day doesn't count."""
def test_partial_first_day_not_enough(self, store):
def test_partial_first_day_keeps_waiting_fact(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")
@@ -303,7 +305,7 @@ class TestGatePartialFirstDay:
_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 result.confidence_state != ConfidenceState.UNSUPPORTED
assert any("full local observation day" in f for f in result.contributing_facts)
+10 -10
View File
@@ -398,16 +398,16 @@ class TestArithmetic:
_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
E_rated = 1.0 * TBW_TO_BYTES
W_t = 512000000000 # lifetime bytes written of the newest published sample
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)
actual_wc = int((_clock() - period_start).total_seconds())
rate = regime_bytes / actual_wc
expected = max(E_rated - W_t, 0) / rate
assert result.headline_remaining_seconds is not None
assert abs(result.headline_remaining_seconds - expected) < 1.0
def test_implied_baseline_formula(self, store):
W_t = 1024 * 1024 * 1000
+221
View File
@@ -0,0 +1,221 @@
"""Staged projection with an evidence ladder (issue #106, ADR 0012).
Every gate is exercised through compute_projection() against SQLite
fixtures built by seed_evidence(): N hours or days of complete evidence
written as hour observations, then derived into day aggregates the same
way a collection run does.
"""
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.day_aggregate import derive_all_days, persist_day_aggregate
from fenris.monitoring_periods import ensure_period_open
from fenris.projection import (
BaselineTier, ConfidenceState, ProjectionStage, compute_projection,
)
from fenris.store import init_store
START = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
HOUR_BYTES = 10 * 1024 ** 3
MODEL = "Samsung SSD 970 EVO Plus 1TB"
@pytest.fixture
def store(tmp_path):
conn = init_store(tmp_path / "test.db")
yield conn
conn.close()
def seed_evidence(conn, hours, baseline="verified", start=START, degraded=False):
"""Seed *hours* hours of complete evidence and return the clock.
Monitoring opens at *start*; each hour is fully sampled and active.
The returned clock is the end of the last observed hour.
"""
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, 'nqn.test', 'SN1', ?, 'FW1', '0x144d', '0x144d', 'pcie')",
(start.isoformat(), "" if degraded else "nqn.test", degraded, MODEL),
)
if baseline:
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 (600, ?, 'v1', '2026-01-01', ?, 1024000000000, ?, ?, "
" '2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')",
("https://example.com/spec" if baseline == "verified" else None, MODEL,
"machine_match" if baseline == "verified" else None,
baseline == "verified"),
)
ensure_period_open(conn, start)
for h in range(hours):
hour = start + timedelta(hours=h)
conn.execute(
"INSERT INTO hour_observations "
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, 3600, 0, 0, 0, ?, 0, 1, 1.0)",
(hour.strftime("%Y-%m-%dT%H:00:00Z"), HOUR_BYTES),
)
written = HOUR_BYTES * (h + 1)
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
"VALUES (?, '/dev/nvme0n1', ?, 0, 1, ?, 0, 100, 1)",
((hour + timedelta(hours=1)).isoformat(), written // 512000, written),
)
for aggregate in derive_all_days(conn):
persist_day_aggregate(conn, aggregate)
conn.commit()
return start + timedelta(hours=hours)
class TestHourStages:
def test_empty_store_has_no_observations(self, store):
result = compute_projection(store, START)
assert result.stage == ProjectionStage.NO_OBSERVATIONS
assert result.headline_remaining_seconds is None
def test_two_hours_is_too_few_for_a_number(self, store):
clock = seed_evidence(store, 2)
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.NO_OBSERVATIONS
assert result.headline_remaining_seconds is None
assert result.hours_observed == 2
def test_three_hours_is_provisional_with_a_number(self, store):
clock = seed_evidence(store, 3)
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.PROVISIONAL
assert result.headline_remaining_seconds is not None
assert result.headline_remaining_seconds > 0
assert result.hours_observed == 3
assert "3 hours observed" in result.contributing_facts
assert "daily cycle not yet seen" in result.contributing_facts
def test_three_hours_shows_short_horizon_spread(self, store):
clock = seed_evidence(store, 3)
result = compute_projection(store, clock)
assert result.scenario_range is not None
assert set(result.scenario_range.rates) <= {1, 3}
assert 1 in result.scenario_range.rates
def test_provisional_rate_is_the_observed_write_rate(self, store):
clock = seed_evidence(store, 3)
result = compute_projection(store, clock)
assert result.write_rate == pytest.approx(HOUR_BYTES / 3600, rel=0.01)
def test_complete_local_day_is_a_fact_not_a_gate(self, store):
clock = seed_evidence(store, 3)
result = compute_projection(store, clock)
assert result.headline_remaining_seconds is not None
assert "waiting for a full local observation day" in result.contributing_facts
def test_provisional_until_24_hours_then_warming(self, store):
clock = seed_evidence(store, 23)
assert compute_projection(store, clock).stage == ProjectionStage.PROVISIONAL
def test_twenty_four_hours_is_warming(self, store):
clock = seed_evidence(store, 24)
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.WARMING
assert "daily cycle not yet seen" not in result.contributing_facts
class TestDayStages:
def test_warming_until_fourteen_qualifying_days(self, store):
clock = seed_evidence(store, 24 * 13)
assert compute_projection(store, clock).stage == ProjectionStage.WARMING
def test_fourteen_days_with_verified_baseline_is_supported(self, store):
clock = seed_evidence(store, 24 * 14)
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.SUPPORTED
assert result.confidence_state == ConfidenceState.SUPPORTED
def test_unverified_baseline_is_limited_after_warm_up(self, store):
clock = seed_evidence(store, 24 * 14, baseline="unverified")
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.LIMITED
assert result.baseline_tier == BaselineTier.UNVERIFIED
def test_degraded_identity_is_limited(self, store):
clock = seed_evidence(store, 24 * 14, degraded=True)
assert compute_projection(store, clock).stage == ProjectionStage.LIMITED
def test_stale_history_is_limited(self, store):
clock = seed_evidence(store, 24 * 14) + timedelta(days=4)
assert compute_projection(store, clock).stage == ProjectionStage.LIMITED
class TestNoBaseline:
def test_write_rate_replaces_lifespan(self, store):
clock = seed_evidence(store, 3, baseline=None)
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.UNAVAILABLE
assert result.headline_remaining_seconds is None
assert result.write_rate == pytest.approx(HOUR_BYTES / 3600, rel=0.01)
def test_no_baseline_is_not_synthesised(self, store):
clock = seed_evidence(store, 24 * 14, baseline=None)
result = compute_projection(store, clock)
assert result.baseline_tier == BaselineTier.NONE
assert result.headline_remaining_seconds is None
assert result.write_rate is not None
class TestShortHorizons:
def test_short_horizons_appear_once_covered(self, store):
clock = seed_evidence(store, 24 * 2)
rates = compute_projection(store, clock).scenario_range.rates
assert 1 in rates
assert 3 not in rates
def test_three_day_horizon_covered_at_four_days(self, store):
clock = seed_evidence(store, 24 * 4)
rates = compute_projection(store, clock).scenario_range.rates
assert {1, 3} <= set(rates)
def test_short_horizons_drop_when_seven_day_covered(self, store):
clock = seed_evidence(store, 24 * 9)
rates = compute_projection(store, clock).scenario_range.rates
assert 7 in rates
assert 1 not in rates and 3 not in rates
class TestEvidenceLadder:
def test_ladder_lists_each_supported_condition_with_reason(self, store):
clock = seed_evidence(store, 3)
ladder = compute_projection(store, clock).evidence_ladder
assert len(ladder) == 8
assert all(c.reason for c in ladder)
assert all(isinstance(c.met, bool) for c in ladder)
def test_ladder_all_met_when_supported(self, store):
clock = seed_evidence(store, 24 * 14)
result = compute_projection(store, clock)
assert result.ladder_met == result.ladder_total == 8
def test_ladder_count_rises_as_evidence_accrues(self, store, tmp_path):
counts = []
for hours in (3, 24 * 2, 24 * 9, 24 * 14):
conn = init_store(tmp_path / ("ladder-%d.db" % hours))
clock = seed_evidence(conn, hours)
counts.append(compute_projection(conn, clock).ladder_met)
conn.close()
assert counts == sorted(counts)
assert counts[0] < counts[-1]
assert len(set(counts)) >= 3
def test_unmet_condition_names_its_reason(self, store):
clock = seed_evidence(store, 3)
unmet = [c for c in compute_projection(store, clock).evidence_ladder if not c.met]
assert any("qualifying days" in c.reason for c in unmet)
+112
View File
@@ -0,0 +1,112 @@
"""CLI and TUI render the projection stage, never pre-gate it (issue #106).
Seams: get_status() and the TUI headline band. Both must show what
compute_projection() reports: the stage, the ladder count, and the full
ladder in the outlook; no caller decides what the projection can show.
"""
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).parent))
from fenris.status import get_status
from fenris.store import init_store
from fenris.tui import FenrisTuiApp
from test_staged_projection import seed_evidence
SERVICES = {
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}
def _status(db, clock):
with patch("fenris.status.query_service_state", return_value=SERVICES):
return get_status(store_path=db, clock_now=clock,
query_services=False, query_journal=False)
@pytest.fixture
def db(tmp_path):
return tmp_path / "observations.db"
class TestCliStatus:
def test_provisional_stage_ladder_count_and_full_ladder(self, db):
conn = init_store(db)
clock = seed_evidence(conn, 3)
conn.close()
out = _status(db, clock)
assert "Provisional" in out
assert "3 hours observed" in out
assert "daily cycle not yet seen" in out
assert "remaining" in out
assert "conditions met" in out
assert "Evidence ladder" in out
assert "[ ] 14 qualifying days" in out
assert "[x] verified baseline" in out
def test_single_sample_is_not_pre_gated(self, db):
conn = init_store(db)
seed_evidence(conn, 1)
conn.close()
out = _status(db, datetime(2026, 9, 1, 1, 0, tzinfo=timezone.utc))
assert "requires at least two samples" not in out
assert "1 of 3 hours observed" in out
def test_no_baseline_shows_write_rate_not_lifespan(self, db):
conn = init_store(db)
clock = seed_evidence(conn, 3, baseline=None)
conn.close()
out = _status(db, clock)
assert "write rate" in out.lower()
assert "GB/day" in out
def test_supported_stage(self, db):
conn = init_store(db)
clock = seed_evidence(conn, 24 * 14)
conn.close()
out = _status(db, clock)
assert "Supported" in out
assert "8 of 8 conditions met" in out
class TestTuiHeadline:
@pytest.mark.asyncio
async def test_provisional_headline_and_ladder(self, db):
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
conn = init_store(db)
seed_evidence(conn, 3, start=now - timedelta(hours=3))
conn.close()
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = FenrisTuiApp(store_path=db)
async with app.run_test(size=(120, 50)):
text = str(app.query_one("#headline-band").render())
assert "Provisional" in text
assert "3 hours observed" in text
assert "conditions met" in text
assert "Evidence ladder" in text
@pytest.mark.asyncio
async def test_single_sample_is_not_pre_gated(self, db):
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
conn = init_store(db)
seed_evidence(conn, 1, start=now - timedelta(hours=1))
conn.close()
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = FenrisTuiApp(store_path=db)
async with app.run_test(size=(120, 50)):
text = str(app.query_one("#headline-band").render())
assert "at least two samples" not in text
def test_callers_carry_no_projection_pre_gate():
root = Path(__file__).parent.parent / "src" / "fenris"
for name in ("tui.py", "status.py"):
assert "at least two samples" not in (root / name).read_text()