feat(projection): add complete observation day gate (issue #94)
This commit is contained in:
@@ -88,6 +88,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||
|
||||
|
||||
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||
br=0, coverage=0.95, samples=24, complete=True):
|
||||
"""Insert a local_days row (issue #94 gate prerequisite)."""
|
||||
if utc_start is None:
|
||||
utc_start = local_date + "T00:00:00+00:00"
|
||||
if utc_end is None:
|
||||
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||
conn.execute(
|
||||
"INSERT INTO local_days "
|
||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||
bw, br, coverage, samples, complete),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
|
||||
"""Insert multiple complete local days to satisfy the issue #94 gate."""
|
||||
for i in range(count):
|
||||
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_local_day(conn, d, bw=bw)
|
||||
|
||||
|
||||
class TestPrecedence:
|
||||
def test_no_baseline_unavailable(self, store):
|
||||
_insert_segment(store)
|
||||
@@ -127,6 +154,7 @@ class TestConfidenceRuleTable:
|
||||
def test_unavailable_no_baseline(self, store):
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert any("no applicable endurance baseline" in f for f in result.contributing_facts)
|
||||
@@ -138,6 +166,7 @@ class TestConfidenceRuleTable:
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=0)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert any("no finite projection" in f for f in result.contributing_facts)
|
||||
@@ -150,12 +179,14 @@ class TestConfidenceRuleTable:
|
||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=1024*1024*100)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
||||
|
||||
def test_limited_degraded_identity(self, store):
|
||||
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
_insert_segment(store, degraded=True)
|
||||
_open_period(store)
|
||||
for i in range(20):
|
||||
@@ -201,6 +232,7 @@ class TestZeroRate:
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=0)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert any("no finite projection from this history" in f for f in result.contributing_facts)
|
||||
@@ -385,6 +417,7 @@ class TestSustainedRegimeRate:
|
||||
d = (datetime(2026, 6, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-28T12:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=30, hour=12))
|
||||
# Regime should be capped at 90 days (from Jun 1 to Sep 30 = 90 days at cutoff)
|
||||
# The 90-day cutoff is Sep 30 - 90 = Jul 1, so regime starts Jul 1
|
||||
@@ -447,6 +480,7 @@ class TestHabitChange:
|
||||
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw_high)
|
||||
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-07", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
|
||||
assert result.habit_change_fact is not None
|
||||
assert "usage habit changed" in result.habit_change_fact
|
||||
@@ -468,6 +502,7 @@ class TestHabitChange:
|
||||
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw_low)
|
||||
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-07", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
|
||||
assert result.habit_change_fact is not None
|
||||
assert "usage habit changed" in result.habit_change_fact
|
||||
@@ -524,6 +559,7 @@ class TestHabitChange:
|
||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
||||
@@ -544,6 +580,7 @@ class TestWarmingGate:
|
||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.warming_fact is not None
|
||||
assert "warming up" in result.warming_fact
|
||||
@@ -560,6 +597,7 @@ class TestWarmingGate:
|
||||
cov = 0.30 if i < 3 else 0.95
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.warming_fact is not None
|
||||
assert "warming up" in result.warming_fact
|
||||
@@ -589,6 +627,7 @@ class TestWarmingGate:
|
||||
cov = 0.30 if i < 3 else 0.95
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.warming_fact is not None
|
||||
|
||||
@@ -602,6 +641,7 @@ class TestWarmingGate:
|
||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
# Should have warming fact but still render
|
||||
assert result.warming_fact is not None
|
||||
@@ -614,6 +654,7 @@ class TestWarmingGate:
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert result.headline_remaining_seconds is None
|
||||
@@ -626,6 +667,7 @@ class TestWarmingGate:
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=0)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert result.headline_remaining_seconds is None
|
||||
@@ -646,6 +688,7 @@ class TestStalenessDrop:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
# Clock is 3 days after last data → staleness > 48h
|
||||
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||
result = compute_projection(store, clock)
|
||||
@@ -661,6 +704,7 @@ class TestStalenessDrop:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.staleness_fact is not None
|
||||
@@ -703,6 +747,7 @@ class TestSegmentBreakProjection:
|
||||
d = (datetime(2026, 9, 16) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-20T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-19", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=20, hour=12))
|
||||
# Prior days exist in store but projection uses current segment
|
||||
# 5 days in segment → regime_days = 5
|
||||
@@ -727,6 +772,7 @@ class TestSegmentBreakProjection:
|
||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-27", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
|
||||
# Young regime (3 days) → Limited, not enough data for full confidence
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
@@ -753,6 +799,7 @@ class TestSegmentBreakProjection:
|
||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-27", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
|
||||
# Prior history quarantined; only 3 days in new segment
|
||||
assert result.regime_days is not None
|
||||
@@ -771,6 +818,7 @@ class TestDegradedIdentity:
|
||||
_insert_segment(store, identity_key=None, degraded=True)
|
||||
_open_period(store)
|
||||
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
# No baseline → Unavailable
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
@@ -787,6 +835,7 @@ class TestDegradedIdentity:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
assert any("controller identity unavailable" in f for f in result.contributing_facts)
|
||||
@@ -802,6 +851,7 @@ class TestDegradedIdentity:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
# Stale clock (>48h)
|
||||
clock = datetime(2026, 10, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
result = compute_projection(store, clock)
|
||||
@@ -857,6 +907,7 @@ class TestIdentityChangeBlankKeys:
|
||||
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-25", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||
# Prior history quarantined; only 5 days in new segment
|
||||
assert result.regime_days is not None
|
||||
@@ -880,6 +931,7 @@ class TestIdentityChangeBlankKeys:
|
||||
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-25", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||
assert result.regime_days is not None
|
||||
assert result.regime_days <= 5
|
||||
@@ -895,6 +947,7 @@ class TestIdentityChangeBlankKeys:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-25", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||
# All 25 days in same segment (equal blanks continue)
|
||||
assert result.regime_days is not None
|
||||
|
||||
Reference in New Issue
Block a user