diff --git a/docs/superpowers/plans/2026-05-12-data-quality-audit.md b/docs/superpowers/plans/2026-05-12-data-quality-audit.md index 3b6a3dd..0819d88 100644 --- a/docs/superpowers/plans/2026-05-12-data-quality-audit.md +++ b/docs/superpowers/plans/2026-05-12-data-quality-audit.md @@ -246,8 +246,12 @@ def _expected_ticks(since: datetime, until: datetime) -> int: ) if first_tick >= until: return 0 - span = until - first_tick - return int(span.total_seconds() // (_TICK_INTERVAL_MIN * 60)) + 1 + # Count ticks in [first_tick, until): the count is + # ceil(span_minutes / 15). floor() under-counts at non-multiple + # spans; floor()+1 over-counts at aligned multiples. Only + # ceil() works for both. Requires `import math` at the top. + span_seconds = (until - first_tick).total_seconds() + return math.ceil(span_seconds / (_TICK_INTERVAL_MIN * 60)) ``` - [ ] **Step 4: Run tests to verify they pass** diff --git a/src/cerbero_bite/analysis/data_audit.py b/src/cerbero_bite/analysis/data_audit.py index 25be057..41c6602 100644 --- a/src/cerbero_bite/analysis/data_audit.py +++ b/src/cerbero_bite/analysis/data_audit.py @@ -12,6 +12,7 @@ parameters. To tune a threshold, edit this file. from __future__ import annotations +import math import sqlite3 import statistics from dataclasses import dataclass, field @@ -117,5 +118,9 @@ def _expected_ticks(since: datetime, until: datetime) -> int: ) if first_tick >= until: return 0 - span = until - first_tick - return int(span.total_seconds() // (_TICK_INTERVAL_MIN * 60)) + # Count ticks in [first_tick, until): the largest k with + # first_tick + k*15min < until is ceil(span/15) - 1, so the + # count is ceil(span_minutes / 15). floor() under-counts at + # aligned multiples and would mis-count non-aligned spans. + span_seconds = (until - first_tick).total_seconds() + return math.ceil(span_seconds / (_TICK_INTERVAL_MIN * 60)) diff --git a/tests/unit/test_data_audit.py b/tests/unit/test_data_audit.py index 34e02cc..26e3195 100644 --- a/tests/unit/test_data_audit.py +++ b/tests/unit/test_data_audit.py @@ -26,3 +26,20 @@ def test_expected_ticks_unaligned_since_rounds_up() -> None: until = datetime(2026, 5, 12, 12, 30, tzinfo=UTC) # First aligned tick after 12:07 is 12:15, then 12:30 is excluded assert _expected_ticks(since, until) == 1 + + +def test_expected_ticks_non_multiple_span_uses_ceiling() -> None: + # Span = 16 min (12:00 → 12:16): ticks 12:00 AND 12:15 are < 12:16. + # floor(16/15) = 1 would under-count; the correct answer is 2. + since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) + until = datetime(2026, 5, 12, 12, 16, tzinfo=UTC) + assert _expected_ticks(since, until) == 2 + + +def test_expected_ticks_exact_quarter_boundary_is_excluded() -> None: + # Until landing exactly on a */15 tick: that tick is NOT counted + # because the window is half-open on the right. + since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) + until = datetime(2026, 5, 12, 12, 45, tzinfo=UTC) + # Ticks at 12:00, 12:15, 12:30 → 3 (12:45 excluded) + assert _expected_ticks(since, until) == 3