feat(#76): anchor scenario windows at evidence endpoint T

Headline and scenario rates now describe exact evidence-supported
monitored spans anchored at the latest published usage-evidence
endpoint T, not clock_now. Reader refresh alone never moves T or
dilutes rates.

- Add horizon_reasons field to ScenarioRange for specific unavailability facts
- Modify _compute_horizon_rate to use exact trailing 7/28/90×86400-second starts from T
- Show specific reasons for affected horizons (e.g., "starts before earliest data")
- Update TUI and CLI to display horizon-specific reasons
- Add 6 new tests for evidence-anchored projection rates

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
xavierk
2026-09-14 11:11:50 +05:30
co-authored by CommandCodeBot
parent 5d916ee97f
commit 4f2f30abac
4 changed files with 176 additions and 18 deletions
+39 -11
View File
@@ -73,6 +73,7 @@ class ScenarioRange:
rates: Dict[int, float]
min_days: int
max_days: int
horizon_reasons: Dict[int, str] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -234,20 +235,39 @@ def _compute_regime_rate(days, conn, regime_start_day, clock_now):
return regime_bytes / regime_wc, regime_bytes, regime_wc
def _compute_horizon_rate(days, conn, horizon_days, clock_now):
cutoff = (clock_now - timedelta(days=horizon_days)).strftime("%Y-%m-%d")
def _compute_horizon_rate(days, conn, horizon_days, evidence_endpoint):
"""Compute horizon rate anchored at the latest evidence endpoint T.
Uses exact trailing horizon_days × 86400 seconds from T, not clock_now.
Reader refresh alone never moves T or dilutes rates.
Returns (rate, reason) where reason is None on success or a string
describing why the rate is unavailable.
"""
if not days:
return None, "no observation history"
# T is the latest evidence endpoint — the end of the last day aggregate
T = datetime.fromisoformat(days[-1]["day"] + "T23:59:59+00:00")
# Exact trailing start: T minus horizon_days × 86400 seconds
h_start = T - timedelta(days=horizon_days)
cutoff = h_start.strftime("%Y-%m-%d")
# History must span the full horizon — no placeholders
if not days or days[0]["day"] > cutoff:
return None
if days[0]["day"] > cutoff:
return None, "%d-day window starts before earliest data" % horizon_days
h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff)
covered = sum(1 for d in days if d["day"] >= cutoff)
if covered == 0:
return None
h_start = datetime.fromisoformat(cutoff + "T00:00:00+00:00")
h_wc = _wall_clock_in_range(conn, h_start, clock_now)
return None, "%d-day window has no data" % horizon_days
h_wc = _wall_clock_in_range(conn, h_start, T)
if h_wc <= 0:
return None
return h_bytes / h_wc
return None, "%d-day window has no monitored wall-clock time" % horizon_days
return h_bytes / h_wc, None
# ---------------------------------------------------------------------------
@@ -508,12 +528,20 @@ def compute_projection(conn, clock_now):
scenario = None
horizon_rates = {}
horizon_reasons = {}
for h in HORIZON_DAYS:
hr = _compute_horizon_rate(all_days, conn, h, clock_now)
hr, reason = _compute_horizon_rate(all_days, conn, h, clock_now)
if hr is not None:
horizon_rates[h] = hr
else:
horizon_reasons[h] = reason
if horizon_rates:
scenario = ScenarioRange(rates=horizon_rates, min_days=min(horizon_rates), max_days=max(horizon_rates))
scenario = ScenarioRange(
rates=horizon_rates,
min_days=min(horizon_rates),
max_days=max(horizon_rates),
horizon_reasons=horizon_reasons,
)
total_days_count = len(segment_days)
days_below_coverage = sum(1 for d in segment_days