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:
co-authored by
CommandCodeBot
parent
5d916ee97f
commit
4f2f30abac
+39
-11
@@ -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
|
||||
|
||||
@@ -386,14 +386,17 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append("%s evidence" % state_label)
|
||||
lines.append("")
|
||||
|
||||
# --- Scenario range (§6.5) ---
|
||||
if proj.scenario_range and proj.scenario_range.rates:
|
||||
# --- Scenario range (§6.5) with horizon reasons ---
|
||||
if proj.scenario_range:
|
||||
parts = []
|
||||
for horizon in sorted(proj.scenario_range.rates.keys()):
|
||||
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
|
||||
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
|
||||
lines.append("scenario range: %s" % " · ".join(parts))
|
||||
lines.append("")
|
||||
for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()):
|
||||
parts.append("%dd: %s" % (horizon, reason))
|
||||
if parts:
|
||||
lines.append("scenario range: %s" % " · ".join(parts))
|
||||
lines.append("")
|
||||
|
||||
# --- PU context line (§6.1) ---
|
||||
lines.append(proj.pu_context_line)
|
||||
|
||||
+6
-2
@@ -1205,13 +1205,17 @@ class FenrisTuiApp(App):
|
||||
)
|
||||
|
||||
def _format_scenario(self, proj: ProjectionResult) -> str:
|
||||
"""Format scenario range (spec §6.5)."""
|
||||
if not proj.scenario_range or not proj.scenario_range.rates:
|
||||
"""Format scenario range with horizon reasons (spec §6.5)."""
|
||||
if not proj.scenario_range:
|
||||
return ""
|
||||
parts = []
|
||||
for horizon in sorted(proj.scenario_range.rates.keys()):
|
||||
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
|
||||
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
|
||||
for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()):
|
||||
parts.append("%dd: %s" % (horizon, reason))
|
||||
if not parts:
|
||||
return ""
|
||||
return "[bold]Scenario range[/bold] · %s" % " · ".join(parts)
|
||||
|
||||
# --- Actions ---
|
||||
|
||||
Reference in New Issue
Block a user