fix(analysis): _expected_ticks usa ceiling division (no off-by-one)
Il piano originale aveva `floor(span/15) + 1` che over-conta a span allineati (span=60min → 5 invece di 4). Il primo fix dell'implementer (`floor(span/15)`) under-conta a span non-allineati (span=16min → 1 invece di 2). Solo `ceil(span/15)` è corretto in entrambi i casi. Aggiunti 2 test che coprono gli scenari non-allineato e boundary-esatto per impedire regressioni. Plan doc allineato. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -246,8 +246,12 @@ def _expected_ticks(since: datetime, until: datetime) -> int:
|
|||||||
)
|
)
|
||||||
if first_tick >= until:
|
if first_tick >= until:
|
||||||
return 0
|
return 0
|
||||||
span = until - first_tick
|
# Count ticks in [first_tick, until): the count is
|
||||||
return int(span.total_seconds() // (_TICK_INTERVAL_MIN * 60)) + 1
|
# 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**
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ parameters. To tune a threshold, edit this file.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import statistics
|
import statistics
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -117,5 +118,9 @@ def _expected_ticks(since: datetime, until: datetime) -> int:
|
|||||||
)
|
)
|
||||||
if first_tick >= until:
|
if first_tick >= until:
|
||||||
return 0
|
return 0
|
||||||
span = until - first_tick
|
# Count ticks in [first_tick, until): the largest k with
|
||||||
return int(span.total_seconds() // (_TICK_INTERVAL_MIN * 60))
|
# 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))
|
||||||
|
|||||||
@@ -26,3 +26,20 @@ def test_expected_ticks_unaligned_since_rounds_up() -> None:
|
|||||||
until = datetime(2026, 5, 12, 12, 30, tzinfo=UTC)
|
until = datetime(2026, 5, 12, 12, 30, tzinfo=UTC)
|
||||||
# First aligned tick after 12:07 is 12:15, then 12:30 is excluded
|
# First aligned tick after 12:07 is 12:15, then 12:30 is excluded
|
||||||
assert _expected_ticks(since, until) == 1
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user