Skip to content

Commit 7a4f0a0

Browse files
authored
fix: use SCHEDULING_EPSILON in greedy/backward scheduling loops (#1113)
Comparing accumulated float hours against 0 left ~1e-14 residue, so a task whose duration is an exact multiple of max_hours_per_day got one extra day and could be wrongly reported unschedulable against a tight deadline. Match the balanced strategy and compare against SCHEDULING_EPSILON. Closes #1089
1 parent 6d07242 commit 7a4f0a0

4 files changed

Lines changed: 112 additions & 4 deletions

File tree

packages/taskdog-core/src/taskdog_core/application/services/optimization/backward_optimization_strategy.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from datetime import date, datetime, timedelta
44

5+
from taskdog_core.application.constants.optimization import SCHEDULING_EPSILON
56
from taskdog_core.application.dto.optimize_params import OptimizeParams
67
from taskdog_core.application.dto.optimize_result import OptimizeResult
78
from taskdog_core.application.services.optimization.allocation_helpers import (
@@ -87,7 +88,7 @@ def _allocate_task(
8788
schedule_end = None
8889
temp_allocations: list[tuple[date, float, datetime]] = []
8990

90-
while remaining_hours > 0:
91+
while remaining_hours > SCHEDULING_EPSILON:
9192
if not params.include_all_days and not is_workday(
9293
current_date, params.holiday_checker
9394
):
@@ -106,7 +107,7 @@ def _allocate_task(
106107
params.max_hours_per_day,
107108
)
108109

109-
if available_hours > 0:
110+
if available_hours > SCHEDULING_EPSILON:
110111
allocated = min(remaining_hours, available_hours)
111112
temp_allocations.append((date_obj, allocated, current_date))
112113
remaining_hours -= allocated

packages/taskdog-core/src/taskdog_core/application/services/optimization/greedy_based_optimization_strategy.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from datetime import date, datetime, timedelta
44

5+
from taskdog_core.application.constants.optimization import SCHEDULING_EPSILON
56
from taskdog_core.application.dto.optimize_params import OptimizeParams
67
from taskdog_core.application.dto.optimize_result import OptimizeResult
78
from taskdog_core.application.services.optimization.allocation_helpers import (
@@ -116,7 +117,7 @@ def _allocate_task(
116117
schedule_end = None
117118
task_daily_allocations: dict[date, float] = {}
118119

119-
while remaining_hours > 0:
120+
while remaining_hours > SCHEDULING_EPSILON:
120121
if not params.include_all_days and not is_workday(
121122
current_date, params.holiday_checker
122123
):
@@ -137,7 +138,7 @@ def _allocate_task(
137138
params.max_hours_per_day,
138139
)
139140

140-
if available_hours > 0:
141+
if available_hours > SCHEDULING_EPSILON:
141142
if schedule_start is None:
142143
schedule_start = current_date
143144

packages/taskdog-core/tests/application/services/optimization/test_backward_optimization_strategy.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,55 @@ def test_backward_schedules_task_due_today_after_deadline_time(self):
213213
updated_task = self.repository.get_by_id(task.id)
214214
assert updated_task is not None
215215
assert date(2025, 10, 20) in updated_task.daily_allocations
216+
217+
def test_backward_exact_multiple_of_fractional_max_hours(self):
218+
"""Fractional hours that divide evenly must not spill onto an extra day.
219+
220+
20.1h at 6.7h/day is exactly 3 days, but the subtraction leaves ~1.8e-15
221+
rather than 0.0, so a bare `remaining_hours > 0` check walks back one day
222+
too far and allocates the residue there.
223+
"""
224+
task = self.create_task(
225+
"Fractional Task",
226+
estimated_duration=20.1,
227+
deadline=datetime(2025, 10, 22, 18, 0, 0),
228+
)
229+
230+
result = self.optimize_schedule(
231+
start_date=datetime(2025, 10, 20, 9, 0, 0),
232+
max_hours_per_day=6.7,
233+
)
234+
235+
assert len(result.successful_tasks) == 1
236+
assert len(result.failed_tasks) == 0
237+
238+
# Mon-Wed, working back from the Wednesday deadline
239+
self.assert_task_scheduled(
240+
task, expected_end=datetime(2025, 10, 22, 23, 59, 59)
241+
)
242+
243+
updated_task = self.repository.get_by_id(task.id)
244+
assert updated_task is not None
245+
assert len(updated_task.daily_allocations) == 3
246+
assert date(2025, 10, 17) not in updated_task.daily_allocations
247+
248+
def test_backward_exact_fit_against_start_date_is_schedulable(self):
249+
"""A task that exactly fills the days from start_date to deadline must schedule.
250+
251+
20.1h at 6.7h/day needs Mon-Wed and start_date is Monday. The float residue
252+
makes the loop reach back to the previous Friday, before start_date, so the
253+
task is wrongly reported unschedulable.
254+
"""
255+
self.create_task(
256+
"Exact Fit Task",
257+
estimated_duration=20.1,
258+
deadline=datetime(2025, 10, 22, 18, 0, 0),
259+
)
260+
261+
result = self.optimize_schedule(
262+
start_date=datetime(2025, 10, 20, 9, 0, 0),
263+
max_hours_per_day=6.7,
264+
)
265+
266+
assert len(result.failed_tasks) == 0
267+
assert len(result.successful_tasks) == 1

packages/taskdog-core/tests/application/services/optimization/test_greedy_optimization_strategy.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,57 @@ def test_greedy_schedules_task_due_today_after_deadline_time(self):
197197
updated_task = self.repository.get_by_id(task.id)
198198
assert updated_task is not None
199199
assert date(2025, 10, 20) in updated_task.daily_allocations
200+
201+
def test_greedy_exact_multiple_of_fractional_max_hours(self):
202+
"""Fractional hours that divide evenly must not spill onto an extra day.
203+
204+
20.1h at 6.7h/day is exactly 3 days, but 20.1 - 6.7 - 6.7 - 6.7 leaves
205+
~1.8e-15 rather than 0.0, so a bare `remaining_hours > 0` check runs a
206+
fourth day and allocates the residue there.
207+
"""
208+
task = self.create_task(
209+
"Fractional Task",
210+
estimated_duration=20.1,
211+
deadline=datetime(2025, 10, 31, 18, 0, 0),
212+
)
213+
214+
result = self.optimize_schedule(
215+
start_date=datetime(2025, 10, 20, 9, 0, 0),
216+
max_hours_per_day=6.7,
217+
)
218+
219+
assert len(result.successful_tasks) == 1
220+
assert len(result.failed_tasks) == 0
221+
222+
# Mon-Wed, not Mon-Thu
223+
self.assert_task_scheduled(
224+
task,
225+
expected_start=datetime(2025, 10, 20, 0, 0, 0),
226+
expected_end=datetime(2025, 10, 22, 23, 59, 59),
227+
)
228+
229+
updated_task = self.repository.get_by_id(task.id)
230+
assert updated_task is not None
231+
assert len(updated_task.daily_allocations) == 3
232+
assert date(2025, 10, 23) not in updated_task.daily_allocations
233+
234+
def test_greedy_exact_fit_against_deadline_is_schedulable(self):
235+
"""A task that exactly fills the days up to its deadline must schedule.
236+
237+
20.1h at 6.7h/day needs Mon-Wed and the deadline is Wednesday. The float
238+
residue pushes allocation to Thursday, past the deadline, so the task is
239+
wrongly reported unschedulable.
240+
"""
241+
self.create_task(
242+
"Exact Fit Task",
243+
estimated_duration=20.1,
244+
deadline=datetime(2025, 10, 22, 18, 0, 0),
245+
)
246+
247+
result = self.optimize_schedule(
248+
start_date=datetime(2025, 10, 20, 9, 0, 0),
249+
max_hours_per_day=6.7,
250+
)
251+
252+
assert len(result.failed_tasks) == 0
253+
assert len(result.successful_tasks) == 1

0 commit comments

Comments
 (0)