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:
+49
-35
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user