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
+10
View File
@@ -9,6 +9,16 @@ backfill releases from before this changelog.
## [Unreleased]
### Added
- Show a provisional usage-adjusted theoretical lifespan after 3 observed hours, with the hours observed and a short-horizon spread, instead of waiting for a full local day.
- Show the projection stage and an evidence ladder of Supported conditions, each met or unmet with its reason, so confidence visibly strengthens with each collection run.
- Show the sustained-regime write rate when no endurance baseline applies.
### Changed
- Treat the complete-local-day requirement as a contributing fact rather than a gate, and stop the TUI and CLI from deciding when a projection can be shown.
## [0.6.0] - 2026-09-29
### Added
+199 -93
View File
@@ -20,6 +20,11 @@ Arithmetic (§6.3):
E_rated = entered_TBW × 10¹² bytes
E_implied = 100 · W_t / p (1 ≤ p ≤ 254)
Staging (ADR 0012): the result carries a projection stage, the evidence
ladder (each Supported condition met or unmet with its reason) and the
sustained-regime write rate, so callers render the stage and never
pre-gate the projection themselves.
Criteria: PR-1–PR-17, CI-4.
"""
import sqlite3
@@ -35,6 +40,9 @@ from .monitoring_periods import interval_within_one_monitoring_period
# ---------------------------------------------------------------------------
HORIZON_DAYS = (7, 28, 90)
SHORT_HORIZON_DAYS = (1, 3)
PROVISIONAL_MIN_HOURS = 3
DAILY_CYCLE_HOURS = 24
TBW_TO_BYTES = 10 ** 12
IMPLIED_P_MIN = 1
IMPLIED_P_MAX = 254
@@ -62,6 +70,15 @@ class ConfidenceState(Enum):
SUPPORTED = "Supported"
class ProjectionStage(Enum):
NO_OBSERVATIONS = "No observations yet"
PROVISIONAL = "Provisional"
WARMING = "Warming"
LIMITED = "Limited"
SUPPORTED = "Supported"
UNAVAILABLE = "Unavailable"
class BaselineTier(Enum):
VERIFIED = "verified_override"
UNVERIFIED = "unverified_override"
@@ -77,6 +94,14 @@ class ScenarioRange:
horizon_reasons: Dict[int, str] = field(default_factory=dict)
@dataclass(frozen=True)
class LadderCondition:
"""One Supported condition from ADR 0002 §8, met or unmet with its reason."""
name: str
met: bool
reason: str
@dataclass(frozen=True)
class ProjectionResult:
confidence_state: ConfidenceState
@@ -94,6 +119,25 @@ class ProjectionResult:
degraded_identity_fact: Optional[str]
zero_rate_fact: Optional[str]
qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress
stage: ProjectionStage = ProjectionStage.NO_OBSERVATIONS
evidence_ladder: List[LadderCondition] = field(default_factory=list)
write_rate: Optional[float] = None # sustained-regime rate, bytes per second
hours_observed: int = 0
@property
def stage_label(self) -> str:
"""Display wording; observations that are too few are not "none"."""
if self.stage == ProjectionStage.NO_OBSERVATIONS and self.hours_observed > 0:
return "Collecting observations"
return self.stage.value
@property
def ladder_met(self) -> int:
return sum(1 for c in self.evidence_ladder if c.met)
@property
def ladder_total(self) -> int:
return len(self.evidence_ladder)
# ---------------------------------------------------------------------------
@@ -206,6 +250,28 @@ def _wall_clock_in_range(conn, start, end):
return total
def _get_hours_observed(conn, segment_opened_at, days):
"""Hours of sampled evidence in the current segment.
Counted from hour observations. A day aggregate without any hour rows
(imported or pruned history) stands for a whole observed day.
"""
hour_floor = segment_opened_at[:13] if segment_opened_at else ""
cursor = conn.execute(
"SELECT substr(hour, 1, 10), COUNT(*) FROM hour_observations "
"WHERE sample_count > 0 AND hour >= ? GROUP BY 1",
(hour_floor,),
)
by_day = dict(cursor.fetchall())
total = 0
for d in days:
if d["day"] in by_day:
total += by_day.pop(d["day"])
elif d["sample_count"] > 0:
total += DAILY_CYCLE_HOURS
return total + sum(by_day.values())
# ---------------------------------------------------------------------------
# Baseline resolution (§6.1, §6.2)
# ---------------------------------------------------------------------------
@@ -378,84 +444,88 @@ def _detect_habit_change(days):
# Confidence rule table (§6.7)
# ---------------------------------------------------------------------------
def _evaluate_confidence(tier, rate, regime_days, days, current_segment,
clock_now, warming_days, warming_low_coverage,
habit_change, staleness_hours, scenario_range):
facts = []
DEGRADED_IDENTITY_FACT = "controller identity unavailable — replacement detection relies on write-counter continuity only"
if tier == BaselineTier.NONE:
facts.append("no applicable endurance baseline")
return ConfidenceState.UNSUPPORTED, facts
if rate is None or rate <= 0:
facts.append("no finite projection from this history")
return ConfidenceState.UNSUPPORTED, facts
def _build_evidence_ladder(tier, regime_days, days, current_segment,
staleness_hours, scenario_range):
"""The eight Supported conditions of ADR 0002 §8, each met or unmet."""
ladder = []
supported_facts = []
failing = False
def add(name, met, met_reason, unmet_reason):
ladder.append(LadderCondition(name, met, met_reason if met else unmet_reason))
# 1. Verified baseline
if tier != BaselineTier.VERIFIED:
failing = True
else:
supported_facts.append("verified manufacturer TBW")
add("verified baseline", tier == BaselineTier.VERIFIED,
"verified manufacturer TBW",
{BaselineTier.UNVERIFIED: "baseline is unverified (user-supplied)",
BaselineTier.IMPLIED: "baseline is implied from vendor wear — coarse"}.get(
tier, "no applicable endurance baseline"))
# 2. >= 14 qualifying days
qualifying = sum(1 for d in days if d["coverage"] >= WARMING_COVERAGE_FLOOR and d["sample_count"] > 0)
if qualifying < WARMING_MIN_DAYS:
failing = True
else:
supported_facts.append("%d calendar days" % qualifying)
qualifying = sum(1 for d in days
if d["coverage"] >= WARMING_COVERAGE_FLOOR and d["sample_count"] > 0)
add("14 qualifying days", qualifying >= WARMING_MIN_DAYS,
"%d calendar days" % qualifying,
"%d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS))
# 3. Coverage >= 80%
total_wc = len(days) * 86400
total_known = sum(int(d["coverage"] * 86400) for d in days)
avg_cov = total_known / total_wc if total_wc > 0 else 0.0
if avg_cov < SUPPORTED_COVERAGE_FLOOR:
failing = True
add("coverage at least 80%", avg_cov >= SUPPORTED_COVERAGE_FLOOR,
"%d%% interval coverage" % int(avg_cov * 100),
"interval coverage %d%% (≥%d%% required)"
% (int(avg_cov * 100), int(SUPPORTED_COVERAGE_FLOOR * 100)))
if staleness_hours is None:
add("fresh data", False, "", "no observation days yet")
else:
supported_facts.append("%d%% interval coverage" % int(avg_cov * 100))
add("fresh data", staleness_hours <= STALENESS_HOURS, "recent data",
"newest data %dh old (≥%dh)" % (staleness_hours, STALENESS_HOURS))
# 4. Fresh (< 48h)
if staleness_hours is not None and staleness_hours > STALENESS_HOURS:
failing = True
elif staleness_hours is not None:
supported_facts.append("recent data")
# 5. Horizon agreement
if scenario_range is not None and len(scenario_range.rates) >= 1:
if scenario_range is None or not scenario_range.rates:
add("horizons agree", False, "", "no scenario horizon covered yet")
else:
rl = list(scenario_range.rates.values())
if min(rl) > 0 and max(rl) / min(rl) > HORIZON_AGREEMENT_FACTOR:
failing = True
else:
supported_facts.append("%d weekly cycles" % len(scenario_range.rates))
else:
failing = True
spread = max(rl) / min(rl) if min(rl) > 0 else None
agree = spread is None or spread <= HORIZON_AGREEMENT_FACTOR
add("horizons agree", agree, "%d weekly cycles" % len(rl),
"horizon rates differ by %.1f× (≤%d× required)"
% (spread or 0.0, HORIZON_AGREEMENT_FACTOR))
# 6. Burst guard
if not failing and len(days) >= BURST_GUARD_LOOKBACK:
t28 = sum(d["bytes_written"] for d in days[-BURST_GUARD_LOOKBACK:])
for d in days[-BURST_GUARD_LOOKBACK:]:
if t28 > 0 and d["bytes_written"] >= BURST_GUARD_FRACTION * t28:
failing = True
break
if not failing:
supported_facts.append("no burst days")
recent = days[-BURST_GUARD_LOOKBACK:]
t28 = sum(d["bytes_written"] for d in recent)
burst = t28 > 0 and any(d["bytes_written"] >= BURST_GUARD_FRACTION * t28 for d in recent)
add("no burst day", bool(recent) and not burst, "no burst days",
"one day carries ≥%d%% of trailing writes" % int(BURST_GUARD_FRACTION * 100)
if burst else "no observation days yet")
# 7. Regime >= 7 days
if regime_days < YOUNG_REGIME_DAYS:
failing = True
add("regime at least 7 days", regime_days >= YOUNG_REGIME_DAYS,
"regime %d days" % regime_days,
"regime only %d days old (≥%d required)" % (regime_days, YOUNG_REGIME_DAYS))
# 8. Degraded identity
if current_segment and current_segment.get("identity_degraded"):
failing = True
facts.append("controller identity unavailable — replacement detection relies on write-counter continuity only")
degraded = bool(current_segment and current_segment.get("identity_degraded"))
add("controller identity", not degraded, "controller identity available",
DEGRADED_IDENTITY_FACT)
return ladder
if not failing:
return ConfidenceState.SUPPORTED, supported_facts
# Limited
limited_facts = list(supported_facts)
def _evaluate_confidence(tier, rate, regime_days, days, current_segment,
habit_change, staleness_hours, scenario_range):
"""Return (confidence state, facts, evidence ladder)."""
ladder = _build_evidence_ladder(tier, regime_days, days, current_segment,
staleness_hours, scenario_range)
if tier == BaselineTier.NONE:
return ConfidenceState.UNSUPPORTED, ["no applicable endurance baseline"], ladder
if rate is None or rate <= 0:
return ConfidenceState.UNSUPPORTED, ["no finite projection from this history"], ladder
met_facts = [c.reason for c in ladder if c.met]
if all(c.met for c in ladder):
return ConfidenceState.SUPPORTED, met_facts, ladder
# Limited: met facts plus the contributing failures
limited_facts = list(met_facts)
if staleness_hours is not None and staleness_hours > STALENESS_HOURS:
limited_facts.append("newest data %dh old (≥48h)" % staleness_hours)
if habit_change is not None:
@@ -463,11 +533,27 @@ def _evaluate_confidence(tier, rate, regime_days, days, current_segment,
if regime_days < YOUNG_REGIME_DAYS:
limited_facts.append("regime only %d days old (≥7 required)" % regime_days)
if current_segment and current_segment.get("identity_degraded"):
degraded_fact = "controller identity unavailable — replacement detection relies on write-counter continuity only"
if degraded_fact not in limited_facts:
limited_facts.append(degraded_fact)
if DEGRADED_IDENTITY_FACT not in limited_facts:
limited_facts.append(DEGRADED_IDENTITY_FACT)
return ConfidenceState.LIMITED, limited_facts, ladder
return ConfidenceState.LIMITED, limited_facts
def ladder_count_text(result):
"""Headline form of the evidence ladder: how many conditions are met."""
return "%d of %d conditions met" % (result.ladder_met, result.ladder_total)
def ladder_lines(result):
"""Full evidence ladder for the outlook, one line per condition."""
return ["[%s] %s — %s" % ("x" if c.met else " ", c.name, c.reason)
for c in result.evidence_ladder]
def write_rate_gb_day(result):
"""The sustained-regime write rate in GB/day, or None when not yet known."""
if result.write_rate is None:
return None
return result.write_rate * 86400 / 1e9
# ---------------------------------------------------------------------------
@@ -548,6 +634,21 @@ def _has_complete_local_day(conn):
# Main projection function
# ---------------------------------------------------------------------------
def _determine_stage(state, hours_observed, total_days, days_below_coverage):
"""Where the projection stands on its way to support (ADR 0012 §1, §2)."""
if hours_observed < PROVISIONAL_MIN_HOURS:
return ProjectionStage.NO_OBSERVATIONS
if state == ConfidenceState.UNSUPPORTED:
return ProjectionStage.UNAVAILABLE
if hours_observed < DAILY_CYCLE_HOURS:
return ProjectionStage.PROVISIONAL
if total_days < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
return ProjectionStage.WARMING
if state == ConfidenceState.SUPPORTED:
return ProjectionStage.SUPPORTED
return ProjectionStage.LIMITED
def compute_projection(conn, clock_now):
facts = []
habit_change_fact = None
@@ -560,32 +661,14 @@ def compute_projection(conn, clock_now):
tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment)
facts.extend(baseline_facts)
# --- Complete observation day gate (issue #94) ---
# An endurance outlook requires at least one complete local
# midnight-to-midnight calendar day with usable observation evidence.
has_complete_day = _has_complete_local_day(conn)
if not has_complete_day:
# The complete-local-day condition is a fact, not a gate (ADR 0012 §2).
if not _has_complete_local_day(conn):
facts.append("waiting for a full local observation day")
return ProjectionResult(
confidence_state=ConfidenceState.UNSUPPORTED,
contributing_facts=facts,
headline_remaining_seconds=None,
scenario_range=None,
pu_context_line="Percentage Used: unknown" if _get_latest_pu(conn) is None else "Percentage Used: %d%%" % (_get_latest_pu(conn) or 0),
disclosure_text=list(DISCLOSURES),
baseline_tier=tier,
baseline_label=baseline_label,
regime_days=None,
habit_change_fact=None,
warming_fact=None,
staleness_fact=None,
degraded_identity_fact=None,
zero_rate_fact=None,
qualifying_days_progress=None,
)
segment_days = _get_days_in_segment(conn, current_segment["opened_at"]) if current_segment else _get_all_days(conn)
all_days = _get_all_days(conn)
hours_observed = _get_hours_observed(
conn, current_segment["opened_at"] if current_segment else None, segment_days)
regime_start_day = None
habit_change = None
@@ -615,12 +698,15 @@ def compute_projection(conn, clock_now):
scenario = None
horizon_rates = {}
horizon_reasons = {}
for h in HORIZON_DAYS:
for h in SHORT_HORIZON_DAYS + HORIZON_DAYS:
hr, reason = _compute_horizon_rate(all_days, conn, h, clock_now)
if hr is not None:
horizon_rates[h] = hr
else:
elif h in HORIZON_DAYS:
horizon_reasons[h] = reason
if HORIZON_DAYS[0] in horizon_rates:
for h in SHORT_HORIZON_DAYS:
horizon_rates.pop(h, None)
if horizon_rates:
scenario = ScenarioRange(
rates=horizon_rates,
@@ -656,14 +742,30 @@ def compute_projection(conn, clock_now):
facts.append(staleness_fact)
if current_segment and current_segment.get("identity_degraded"):
degraded_identity_fact = "controller identity unavailable — replacement detection relies on write-counter continuity only"
degraded_identity_fact = DEGRADED_IDENTITY_FACT
facts.append(degraded_identity_fact)
state, conf_facts = _evaluate_confidence(
tier, rate, regime_days_count, segment_days, current_segment, clock_now,
qualifying, 0, habit_change, staleness_hours, scenario,
state, conf_facts, ladder = _evaluate_confidence(
tier, rate, regime_days_count, segment_days, current_segment,
habit_change, staleness_hours, scenario,
)
stage = _determine_stage(state, hours_observed, total_days_count, days_below_coverage)
if stage == ProjectionStage.NO_OBSERVATIONS:
# Too little evidence for any number: no rate, no lifespan.
state = ConfidenceState.UNSUPPORTED
rate = None
scenario = None
if hours_observed > 0:
facts.insert(0, "%d of %d hours observed before the first projection"
% (hours_observed, PROVISIONAL_MIN_HOURS))
elif stage == ProjectionStage.PROVISIONAL:
# Short-horizon spread: the rate over the hours observed so far.
scenario = ScenarioRange(rates={SHORT_HORIZON_DAYS[0]: rate},
min_days=SHORT_HORIZON_DAYS[0],
max_days=SHORT_HORIZON_DAYS[0])
facts[:0] = ["%d hours observed" % hours_observed, "daily cycle not yet seen"]
all_facts = list(facts)
for cf in conf_facts:
if cf not in all_facts:
@@ -700,4 +802,8 @@ def compute_projection(conn, clock_now):
degraded_identity_fact=degraded_identity_fact,
zero_rate_fact=zero_rate_fact,
qualifying_days_progress=qualifying_days_progress,
stage=stage,
evidence_ladder=ladder,
write_rate=rate,
hours_observed=hours_observed,
)
+19 -16
View File
@@ -23,7 +23,10 @@ from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .status_composition import StatusComposition
from .projection import compute_projection, ConfidenceState, DISCLOSURES
from .projection import (
compute_projection, ConfidenceState, DISCLOSURES, ProjectionStage, ladder_count_text,
ladder_lines, write_rate_gb_day,
)
from .store import SCHEMA_VERSION
from .init_system import (
query_service_state as _init_query_service_state,
@@ -255,8 +258,7 @@ def check_retired_flag(flag: str) -> Optional[str]:
# ---------------------------------------------------------------------------
def _format_projection(proj, freshness: str, drive_facts: List[str],
config_error: Optional[str],
sample_count: int = 0, day_count: int = 0) -> str:
config_error: Optional[str]) -> str:
"""Format projection details; monitoring status has its own renderer."""
lines = []
@@ -272,15 +274,6 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
lines.append("Enable monitoring: fenris monitor resume")
return "\n".join(lines)
# --- Single sample: awaiting another sample (issue #73 AC3) ---
# Only show awaiting state when there are no day aggregates (e.g., legacy import
# or hand-crafted stores can have 1 sample but sufficient day data for projection)
if sample_count <= 1 and day_count == 0:
lines.append("awaiting another sample")
lines.append("")
lines.append("Collecting usage data — the first projection requires at least two samples.")
return "\n".join(lines)
if proj is None:
lines.append("no projection available")
return "\n".join(lines)
@@ -290,6 +283,9 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
lines.append(headline)
lines.append("")
# --- Stage and ladder count (ADR 0012) ---
lines.append("Projection stage: %s · %s" % (proj.stage_label, ladder_count_text(proj)))
# --- Confidence state + contributing facts (§6.7, §6.11) ---
state_label = proj.confidence_state.value
facts_list = list(proj.contributing_facts) if proj.contributing_facts else []
@@ -305,6 +301,11 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
lines.append("%s evidence" % state_label)
lines.append("")
# --- Full evidence ladder in the outlook (ADR 0012 §5) ---
lines.append("Evidence ladder")
lines.extend(" " + line for line in ladder_lines(proj))
lines.append("")
# --- Scenario range (§6.5) with horizon reasons ---
if proj.scenario_range:
parts = []
@@ -333,8 +334,13 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
def _format_headline(proj) -> str:
"""Format the lifespan headline or its no-projection wording (§6.11)."""
if proj.headline_remaining_seconds is None:
if proj.stage == ProjectionStage.NO_OBSERVATIONS:
return "no projection yet — collecting observations"
if proj.zero_rate_fact:
return "no finite projection from this history"
rate = write_rate_gb_day(proj)
if rate is not None:
return "write rate: %.2f GB/day · no lifespan without an endurance baseline" % rate
if proj.warming_fact:
return proj.warming_fact
return "no projection available"
@@ -507,10 +513,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
proj = compute_projection(conn, clock_now)
except (sqlite3.Error, ValueError, TypeError):
pass
parts.append(_format_projection(
proj, comp.freshness, drive_facts, config_error,
comp.sample_count, comp.day_count,
))
parts.append(_format_projection(proj, comp.freshness, drive_facts, config_error))
if query_journal and (
comp.store_fault or comp.last_collect_ok is False
or comp.freshness in ("missed", "stale")
+22 -9
View File
@@ -18,6 +18,7 @@ from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from zoneinfo import ZoneInfo
from rich.markup import escape
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
@@ -38,8 +39,12 @@ from .activity_plot import VolumePoint, volume_plot
from .projection import (
ConfidenceState,
ProjectionResult,
ProjectionStage,
ScenarioRange,
compute_projection,
ladder_count_text,
ladder_lines,
write_rate_gb_day,
)
from .status import (
CADENCE_DEFAULT_S,
@@ -2061,12 +2066,6 @@ class FenrisTuiApp(App):
self._render_headline(
"[bold]No observations yet[/bold]\n\n[bold]%s[/bold]" % _RESUME_HINT
)
elif comp.sample_count <= 1 and comp.day_count == 0:
# Single sample: awaiting another sample
self._render_headline(
"[bold]Awaiting another sample[/bold]\n\n"
"Collecting usage data — the first projection requires at least two samples."
)
else:
try:
proj = compute_projection(conn, self._clock_now)
@@ -2076,7 +2075,8 @@ class FenrisTuiApp(App):
summary, _, context = headline.partition("\n")
confidence_title, _, facts = confidence.partition("\n")
self._render_headline(summary + "\n" + confidence_title + "\n\n"
+ context + "\n" + facts + "\n" + scenario)
+ context + "\n" + facts + "\n" + scenario
+ "\n\n" + self._format_ladder(proj))
except Exception:
self._render_headline("[bold]No projection available[/bold]")
@@ -2267,8 +2267,14 @@ class FenrisTuiApp(App):
def _format_headline(self, proj: ProjectionResult) -> str:
"""Format the lifespan headline (spec §6.11)."""
if proj.headline_remaining_seconds is None:
if proj.stage == ProjectionStage.NO_OBSERVATIONS:
return "[bold]Usage-adjusted theoretical lifespan: [yellow]no projection yet — collecting observations[/yellow][/bold]"
if proj.zero_rate_fact:
return "[bold]Usage-adjusted theoretical lifespan: [red]no finite projection from this history[/red][/bold]"
rate = write_rate_gb_day(proj)
if rate is not None:
return ("[bold]Write rate: %.2f GB/day[/bold]\nNo lifespan without an endurance baseline"
% rate)
if proj.warming_fact:
return "[bold]Usage-adjusted theoretical lifespan: [yellow]%s[/yellow][/bold]" % proj.warming_fact
return "[bold]Usage-adjusted theoretical lifespan: [red]no projection available[/red][/bold]"
@@ -2295,13 +2301,20 @@ class FenrisTuiApp(App):
if proj.qualifying_days_progress:
facts = proj.qualifying_days_progress + " · " + facts
return "[bold]Projection confidence: [%s]%s[/%s][/bold]\n %s" % (
return "[bold]Projection confidence: [%s]%s[/%s] · %s[/bold]\n %s" % (
color,
proj.confidence_state.value,
proj.stage_label,
color,
ladder_count_text(proj),
facts[:1].upper() + facts[1:],
)
def _format_ladder(self, proj: ProjectionResult) -> str:
"""Format the full evidence ladder for the outlook (ADR 0012 §5)."""
lines = ["[bold]Evidence ladder[/bold] · %s" % ladder_count_text(proj)]
lines.extend(" " + escape(line) for line in ladder_lines(proj))
return "\n".join(lines)
def _format_scenario(self, proj: ProjectionResult) -> str:
"""Format scenario range with horizon reasons (spec §6.5)."""
if not proj.scenario_range:
+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)
+4 -4
View File
@@ -398,15 +398,15 @@ 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
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)
period_end = _clock()
actual_wc = int((period_end - period_start).total_seconds())
actual_wc = int((_clock() - period_start).total_seconds())
rate = regime_bytes / actual_wc
expected = max(E_rated - regime_bytes, 0) / rate
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):
+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()