Let one parent cover what the other cannot afford
A period shared between two parents was cut at its calendar midpoint and each half handed over regardless of whether that parent could pay for it. A window crossing New Year could therefore lose its entire first half to a parent whose budget for that year was spent, while the other parent's budget for the very same days went untouched. In the case that prompted this, eight days were reported as unfundable when none of them were. The cut now falls where the cost is even rather than where the calendar is, since weekends and red days are free and spread unevenly through a period. Whatever one parent still cannot cover is offered to the other before any of it is written off. Each parent keeps one unbroken stretch at home.
This commit is contained in:
@@ -550,6 +550,40 @@ def _candidate_cost(
|
||||
return sum(1 for d in _dates_in(start, end) if not is_free_day(d, holidays_map, overrides))
|
||||
|
||||
|
||||
def _split_dates_by_spendable_cost(
|
||||
dates: list[date],
|
||||
holidays_map: dict[date, str],
|
||||
overrides: dict[date, bool],
|
||||
) -> tuple[list[date], list[date]]:
|
||||
"""§fix (funding-fairness) — split a contiguous run of dates into two
|
||||
|
||||
contiguous halves balanced by *spendable* (non-free) day count rather
|
||||
than raw calendar-day count. Free days (weekends, red days) cost neither
|
||||
parent anything, so a calendar-midpoint split can hand one parent a
|
||||
half that is mostly free days while the other gets nearly the whole
|
||||
cost — an uneven split in the one dimension that actually matters.
|
||||
Keeps the previous tie-break convention: the first half gets the extra
|
||||
spendable day when the count is odd. Falls back to a calendar-midpoint
|
||||
split when the range has no spendable days at all (nothing to balance,
|
||||
and `target == 0` would otherwise degenerate to an all-or-nothing split).
|
||||
"""
|
||||
spendable_total = sum(1 for d in dates if not is_free_day(d, holidays_map, overrides))
|
||||
if spendable_total == 0:
|
||||
mid_index = (len(dates) + 1) // 2
|
||||
return dates[:mid_index], dates[mid_index:]
|
||||
|
||||
target = (spendable_total + 1) // 2
|
||||
count = 0
|
||||
split_at = len(dates)
|
||||
for i, d in enumerate(dates):
|
||||
if not is_free_day(d, holidays_map, overrides):
|
||||
count += 1
|
||||
if count >= target:
|
||||
split_at = i + 1
|
||||
break
|
||||
return dates[:split_at], dates[split_at:]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mutable working ledger — internal to a single `Allocator.propose()` call.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -913,8 +947,13 @@ class _Ledger:
|
||||
if not dates:
|
||||
return [], _new_cost()
|
||||
|
||||
mid_index = (len(dates) + 1) // 2 # first half gets the extra day when odd
|
||||
first_half, second_half = dates[:mid_index], dates[mid_index:]
|
||||
# §fix (funding-fairness) — split by *spendable* days, not calendar
|
||||
# days: a free (weekend/red) day costs neither parent anything, so a
|
||||
# raw date-count split can silently hand one parent a costlier half
|
||||
# than the other even before either parent's own budget is checked.
|
||||
first_half, second_half = _split_dates_by_spendable_cost(
|
||||
dates, self._input.holidays, self._input.day_overrides
|
||||
)
|
||||
parent_a, parent_b = responsible.parents[0], responsible.parents[1]
|
||||
|
||||
resolved_child = self._resolve_wish_child(wish)
|
||||
@@ -928,8 +967,49 @@ class _Ledger:
|
||||
days_b, cost_b = self._fund_solo(
|
||||
wish, second_half, red_cluster, parent_b, REASON_SWAP_SECOND_HALF
|
||||
)
|
||||
|
||||
# §fix (funding-fairness) — a parent's own half can still leave
|
||||
# spendable days unfunded when THEIR resources (typically a per-year
|
||||
# vacation budget) run out, even though the partner's half was fully
|
||||
# funded with resources to spare. `_fund_solo` never raises for an
|
||||
# unfunded day — it just omits it from the returned list (see its
|
||||
# trailing `continue`) — so the gap is exactly "date in the half but
|
||||
# missing from the funded-dates set". Rather than report those days
|
||||
# as a shortfall while the partner's budget sits unused, hand each
|
||||
# half's unfunded leftover to the OTHER parent, who has already
|
||||
# picked up everything their own tier-3 resolution could reach and so
|
||||
# cannot conflict with what's being offered. A leftover date is never
|
||||
# a free/weekend/red day (those are always funded on the first pass,
|
||||
# unconditionally, before tier-2/tier-3 even run) so the
|
||||
# `free_reason_override` passed here is inert — kept `None` rather
|
||||
# than a half-label that could never actually be stamped on anything.
|
||||
# One hand-off per direction is enough: each parent's resources are
|
||||
# independent per (parent, child)/(parent, year) key, so a second
|
||||
# round could not free up anything the first round didn't already see.
|
||||
funded_a_dates = {day.date for day in days_a}
|
||||
funded_b_dates = {day.date for day in days_b}
|
||||
leftover_from_a = [d for d in first_half if d not in funded_a_dates]
|
||||
leftover_from_b = [d for d in second_half if d not in funded_b_dates]
|
||||
|
||||
if leftover_from_b:
|
||||
extra_a, extra_cost_a = self._fund_solo(
|
||||
wish, leftover_from_b, red_cluster, parent_a, None
|
||||
)
|
||||
days_a = days_a + extra_a
|
||||
for k in cost_a:
|
||||
cost_a[k] += extra_cost_a[k]
|
||||
if leftover_from_a:
|
||||
extra_b, extra_cost_b = self._fund_solo(
|
||||
wish, leftover_from_a, red_cluster, parent_b, None
|
||||
)
|
||||
days_b = days_b + extra_b
|
||||
for k in cost_b:
|
||||
cost_b[k] += extra_cost_b[k]
|
||||
|
||||
merged_cost = {k: cost_a[k] + cost_b[k] for k in cost_a}
|
||||
return days_a + days_b, merged_cost
|
||||
all_days = days_a + days_b
|
||||
all_days.sort(key=lambda p: p.date)
|
||||
return all_days, merged_cost
|
||||
|
||||
def _fund_both(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user