fix(projection): regime dynamics, warming gate, habit change detection, horizon coverage

Fixes #26.

Changes:
- Fix warming gate: check total_days < 14 OR days_below_coverage > 2
- Rewrite _detect_habit_change: correct consecutive-day scanning
- Fix _compute_horizon_rate: require history spans full horizon

Tests: 29 new tests covering PR-2,3,6,7,9,15,16. 139 total passing.
This commit is contained in:
xavierk
2026-09-01 23:32:00 +05:30
parent 7802a72606
commit b99ebfe9dd
2 changed files with 610 additions and 35 deletions
+49 -35
View File
@@ -236,6 +236,9 @@ def _compute_regime_rate(days, conn, regime_start_day, clock_now):
def _compute_horizon_rate(days, conn, horizon_days, clock_now):
cutoff = (clock_now - timedelta(days=horizon_days)).strftime("%Y-%m-%d")
# History must span the full horizon — no placeholders
if not days or days[0]["day"] > cutoff:
return None
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:
@@ -252,54 +255,62 @@ def _compute_horizon_rate(days, conn, horizon_days, clock_now):
# ---------------------------------------------------------------------------
def _detect_habit_change(days):
need = HABIT_CHANGE_SHORT_WINDOW + HABIT_CHANGE_LONG_WINDOW + HABIT_CHANGE_CONSECUTIVE_DAYS
"""Detect habit change per spec §6.4.
Trailing 7-day mean >= 2x (or <= 0.5x) the preceding 28-day mean
for 3 consecutive days. Returns (change_day, days_since) or None.
The first divergence day is the earliest day in the consecutive run.
"""
need = HABIT_CHANGE_SHORT_WINDOW + HABIT_CHANGE_LONG_WINDOW
if len(days) < need:
return None
for i in range(len(days) - 1, HABIT_CHANGE_LONG_WINDOW + HABIT_CHANGE_SHORT_WINDOW - 1, -1):
se = i + 1
def _ratio_at(end_idx):
"""Compute 7-day / preceding-28-day mean ratio ending at end_idx."""
if end_idx < HABIT_CHANGE_SHORT_WINDOW - 1:
return None
se = end_idx + 1
ss = se - HABIT_CHANGE_SHORT_WINDOW
s_bytes = sum(d["bytes_written"] for d in days[ss:se])
s_mean = s_bytes / HABIT_CHANGE_SHORT_WINDOW
le = ss
ls = le - HABIT_CHANGE_LONG_WINDOW
if ls < 0:
break
return None
l_bytes = sum(d["bytes_written"] for d in days[ls:le])
l_mean = l_bytes / HABIT_CHANGE_LONG_WINDOW
if l_mean == 0:
return None
return s_mean / l_mean
# Scan backwards from the most recent day
for i in range(len(days) - 1, HABIT_CHANGE_LONG_WINDOW + HABIT_CHANGE_SHORT_WINDOW - 2, -1):
ratio = _ratio_at(i)
if ratio is None:
continue
ratio = s_mean / l_mean
if ratio >= HABIT_CHANGE_UPPER_FACTOR or ratio <= HABIT_CHANGE_LOWER_FACTOR:
consecutive = 0
for j in range(ss, min(ss + HABIT_CHANGE_CONSECUTIVE_DAYS, len(days))):
s2e = j + 1
s2s = s2e - HABIT_CHANGE_SHORT_WINDOW
if s2s < 0:
break
s2_bytes = sum(d["bytes_written"] for d in days[s2s:s2e])
s2_mean = s2_bytes / HABIT_CHANGE_SHORT_WINDOW
l2e = s2s
l2s = l2e - HABIT_CHANGE_LONG_WINDOW
if l2s < 0:
break
l2_bytes = sum(d["bytes_written"] for d in days[l2s:l2e])
l2_mean = l2_bytes / HABIT_CHANGE_LONG_WINDOW
if l2_mean == 0:
break
r = s2_mean / l2_mean
if (ratio >= HABIT_CHANGE_UPPER_FACTOR and r >= HABIT_CHANGE_UPPER_FACTOR) or \
(ratio <= HABIT_CHANGE_LOWER_FACTOR and r <= HABIT_CHANGE_LOWER_FACTOR):
consecutive += 1
else:
break
is_upper = ratio >= HABIT_CHANGE_UPPER_FACTOR
is_lower = ratio <= HABIT_CHANGE_LOWER_FACTOR
if not (is_upper or is_lower):
continue
if consecutive >= HABIT_CHANGE_CONSECUTIVE_DAYS:
change_day = days[ss]["day"]
days_since = (datetime.fromisoformat(days[-1]["day"]) - datetime.fromisoformat(change_day)).days
return change_day, days_since
# Count consecutive days going backwards from i
consecutive = 1
for j in range(i - 1, HABIT_CHANGE_LONG_WINDOW + HABIT_CHANGE_SHORT_WINDOW - 3, -1):
r = _ratio_at(j)
if r is None:
break
if (is_upper and r >= HABIT_CHANGE_UPPER_FACTOR) or \
(is_lower and r <= HABIT_CHANGE_LOWER_FACTOR):
consecutive += 1
else:
break
if consecutive >= HABIT_CHANGE_CONSECUTIVE_DAYS:
change_idx = i - consecutive + 1
change_day = days[change_idx]["day"]
days_since = (datetime.fromisoformat(days[-1]["day"]) - datetime.fromisoformat(change_day)).days
return change_day, days_since
return None
@@ -504,8 +515,11 @@ def compute_projection(conn, clock_now):
if horizon_rates:
scenario = ScenarioRange(rates=horizon_rates, min_days=min(horizon_rates), max_days=max(horizon_rates))
qualifying = sum(1 for d in segment_days if d["coverage"] >= WARMING_COVERAGE_FLOOR and d["sample_count"] > 0)
if qualifying < WARMING_MIN_DAYS:
total_days_count = len(segment_days)
days_below_coverage = sum(1 for d in segment_days
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
qualifying = total_days_count - days_below_coverage
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
facts.append(warming_fact)