File size: 17,038 Bytes
7a3d380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
"""Set-partitioning matheuristic (--solver setpart): route pool + MILP.

The airline crew-scheduling pattern at field-adjuster scale. Heuristics
are excellent at building individual routes but must commit to ONE way
of splitting claims across adjusters; the remaining gap on big days
lives in that split. So:

1. Harvest routes from several diverse heuristic runs (OR-Tools GLS,
   the hybrid, PyVRP under different random seeds).
2. Enrich the pool: try every harvested route on every other eligible
   adjuster (skills/territory/shift permitting), add single-claim
   routes, and re-sequence every candidate to per-route optimality by
   exhaustive branch and bound (routes are short, so this is exact and
   fast).
3. A tiny MILP - one binary per candidate route - picks the best
   combination: each adjuster drives at most one route, each claim is
   covered exactly once or dropped at its usual penalty.

The selection MILP stays small (hundreds of binaries) no matter how
large the day is, because it only chooses among precomputed routes -
it never sequences anything. That is what makes this scale where the
full exact MILP (milp_solver.py) hits its ~25-claim wall.

Guarantee: every source solution is itself a feasible selection in the
pool, so the chosen schedule is never worse than the best heuristic
run that fed it.
"""

from __future__ import annotations

import time

import pulp

import config
import solver as ortools_solver
from data_gen import Adjuster, Claim
from milp_solver import _pick_engine
from solver import Route, Solution, Stop


def _latest_start(c: Claim) -> int:
    """Latest service start: the visit must also finish by window end."""
    return max(c.window_start, c.window_end - c.service_minutes)


def _simulate(seq, k, adj, node, by_id, travel_min):
    """Forward earliest-time pass. Returns total travel minutes, or None
    if infeasible. First-stop wait is unlimited (absorbed by delaying the
    home departure, as OR-Tools does); later waits obey the slack cap."""
    t, prev, travel = adj.shift_start, k, 0
    for pos, cid in enumerate(seq):
        c = by_id[cid]
        leg = travel_min[prev][node[cid]]
        service_start = max(c.window_start, t + leg)
        if service_start > _latest_start(c):
            return None
        if pos > 0 and service_start - (t + leg) > config.MAX_WAIT_MINUTES:
            return None
        travel += leg
        t = service_start + c.service_minutes
        prev = node[cid]
    travel += travel_min[prev][k]
    if t + travel_min[prev][k] > adj.shift_end:
        return None
    return travel


def _optimal_sequence(cids, k, adj, node, by_id, travel_min,
                      budget_s: float = 5.0):
    """Exhaustive branch and bound over stop orders for one claim set.
    Returns (travel_minutes, best_order) or (None, None) if no feasible
    order exists. Wait rule mirrors _simulate (conservative, never
    accepts what the OR-Tools model would reject)."""
    deadline = time.time() + budget_s
    pts = [k] + [node[c] for c in cids]
    min_in = {c: min(travel_min[p][node[c]] for p in pts if p != node[c])
              for c in cids}
    min_home = {c: travel_min[node[c]][k] for c in cids}
    best = [None, None]   # travel, order
    seq = []

    def dfs(prev, t, remaining, travel):
        if time.time() > deadline:
            return
        if not remaining:
            total = travel + travel_min[prev][k]
            if ((best[0] is None or total < best[0])
                    and t + travel_min[prev][k] <= adj.shift_end):
                best[0], best[1] = total, list(seq)
            return
        lb = travel + sum(min_in[c] for c in remaining) \
            + min(min_home[c] for c in remaining)
        if best[0] is not None and lb >= best[0]:
            return
        for cid in list(remaining):
            c = by_id[cid]
            leg = travel_min[prev][node[cid]]
            service_start = max(c.window_start, t + leg)
            if service_start > _latest_start(c):
                continue
            if seq and service_start - (t + leg) > config.MAX_WAIT_MINUTES:
                continue
            remaining.remove(cid)
            seq.append(cid)
            dfs(node[cid], service_start + c.service_minutes,
                remaining, travel + leg)
            seq.pop()
            remaining.add(cid)

    dfs(k, adj.shift_start, set(cids), 0)
    return best[0], best[1]


def _eligible_for_all(adj, k, cids, node, by_id, miles) -> bool:
    return all(config.is_eligible(adj, by_id[cid], miles[k][node[cid]])
               for cid in cids)


def _build_solution(chosen, adjusters, claims, miles, travel_min):
    """Chosen = {adjuster_id: [claim_ids in order]}. Same earliest-time
    reconstruction as milp_solver.solve_as_backend."""
    n_adj = len(adjusters)
    node = {c.claim_id: n_adj + i for i, c in enumerate(claims)}
    by_id = {c.claim_id: c for c in claims}
    routes, served = [], set()
    for k, a in enumerate(adjusters):
        seq = chosen.get(a.adjuster_id, [])
        route = Route(adjuster=a)
        prev, t = k, a.shift_start
        for cid in seq:
            c = by_id[cid]
            leg = travel_min[prev][node[cid]]
            arrival = max(c.window_start, t + leg)
            route.stops.append(Stop(
                claim=c, arrival_min=arrival,
                departure_min=arrival + c.service_minutes,
                travel_miles_from_prev=miles[prev][node[cid]],
                travel_min_from_prev=leg))
            route.total_miles += miles[prev][node[cid]]
            route.total_travel_min += leg
            route.total_service_min += c.service_minutes
            served.add(cid)
            prev, t = node[cid], arrival + c.service_minutes
        if seq:
            route.start_min = (route.stops[0].arrival_min
                               - route.stops[0].travel_min_from_prev)
            route.total_miles += miles[prev][k]
            route.total_travel_min += travel_min[prev][k]
            route.end_min = t + travel_min[prev][k]
        else:
            route.start_min = route.end_min = a.shift_start
        routes.append(route)

    dropped = [c for c in claims if c.claim_id not in served]
    unservable = [c for i, c in enumerate(claims)
                  if not any(config.is_eligible(a, c, miles[k][n_adj + i])
                             for k, a in enumerate(adjusters))]
    travel_total = sum(r.total_travel_min for r in routes)
    return Solution(
        routes=routes, dropped=dropped, unservable=unservable,
        objective=travel_total + sum(
            config.effective_penalty(c.priority, c.age_days)
            for c in dropped),
        total_miles=sum(r.total_miles for r in routes),
        total_travel_min=travel_total)


def solve_setpartition(adjusters: list[Adjuster], claims: list[Claim],
                       miles: list[list[float]],
                       travel_min: list[list[int]],
                       time_limit_s: int = 60, engine: str = "auto",
                       lunch_break: bool = False,
                       balance: bool = False,
                       ) -> tuple[Solution | None, dict]:
    """Run the pool-then-select matheuristic. Returns (Solution, info).

    The time limit is split across the pool-generating heuristic runs;
    pool enrichment and the selection MILP add a few seconds on top.

    Toggles follow the airline recipe: the rules live inside the
    columns, never in the selection model. Sources run toggled where
    they can (ortools, hybrid; PyVRP seeds get balance natively), then
    EVERY candidate card is legalized and priced: with lunch on, a
    card that cannot host a 30-minute break (proven by the same
    interval machinery as the sequence backends) is ejected from the
    pool; with balance on, each card's cost gains BALANCE_COEFFICIENT
    times its exact minimal span. The harvested source orders are
    always offered alongside their re-sequenced variants, so a toggled
    source plan remains a feasible selection and the never-worse
    guarantee holds against toggled sources.
    """
    if any(c.extra_windows for c in claims):
        raise ValueError(
            "split-availability claims (multiple time windows) are not "
            "supported by this backend yet - use ortools, cpsat, or "
            "milp (exact)")
    n_adj = len(adjusters)
    node = {c.claim_id: n_adj + i for i, c in enumerate(claims)}
    by_id = {c.claim_id: c for c in claims}
    t_start = time.time()

    # ---- 1. Diverse heuristic runs -----------------------------------
    T = max(10, time_limit_s)
    budgets = {"ortools": max(3, int(0.20 * T)),
               "hybrid": max(4, int(0.35 * T)),
               "pyvrp-s1": max(3, int(0.125 * T)),
               "pyvrp-s2": max(3, int(0.125 * T))}
    sources: dict[str, Solution] = {}
    sol = ortools_solver.solve(adjusters, claims, miles, travel_min,
                               time_limit_s=budgets["ortools"],
                               lunch_break=lunch_break, balance=balance)
    if sol is not None:
        sources["ortools"] = sol
    try:
        import pyvrp_solver
        sol = pyvrp_solver.solve_hybrid(adjusters, claims, miles,
                                        travel_min,
                                        time_limit_s=budgets["hybrid"],
                                        lunch_break=lunch_break,
                                        balance=balance)
        if sol is not None:
            sources["hybrid"] = sol
        for name, seed in (("pyvrp-s1", 1), ("pyvrp-s2", 2)):
            sol = pyvrp_solver.solve(adjusters, claims, miles, travel_min,
                                     time_limit_s=budgets[name], seed=seed,
                                     balance=balance)
            if sol is not None:
                sources[name] = sol
    except ImportError:
        pass
    if not sources:
        return None, {"status": "no heuristic solution"}
    # The never-worse yardstick must obey the same rules as the
    # selection: with lunch on, the PyVRP seed runs are break-blind,
    # so their objectives are not comparable - they still donate
    # cards (each individually legalized), but cannot set the floor.
    yardstick = {name: s for name, s in sources.items()
                 if not lunch_break or name in ("ortools", "hybrid")}
    if not yardstick:
        yardstick = sources
    best_source_name = min(yardstick,
                           key=lambda s: yardstick[s].objective)
    best_source = yardstick[best_source_name]

    # ---- 2. Route pool: harvest, transplant, re-sequence -------------
    # key = (adjuster_id, frozenset of claim ids) -> (cost, order); the
    # cost is travel minutes, plus the card's span cost under balance.
    pool: dict[tuple, tuple[int, list[str]]] = {}
    k_of = {a.adjuster_id: k for k, a in enumerate(adjusters)}
    adj_of = {a.adjuster_id: a for a in adjusters}
    ejected = [0]
    if lunch_break or balance:
        import sequencer as _seq

    def offer(adj_id, cost, order):
        if cost is None:
            return
        if lunch_break or balance:
            timed = _seq._min_span_times(list(order), k_of[adj_id],
                                         adj_of[adj_id], node, by_id,
                                         travel_min,
                                         lunch_break=lunch_break)
            if timed is None:            # card cannot host the break
                ejected[0] += 1
                return
            if balance:
                cost = cost + config.BALANCE_COEFFICIENT * (
                    timed[2] - timed[1])
        key = (adj_id, frozenset(order))
        if key not in pool or cost < pool[key][0]:
            pool[key] = (cost, list(order))

    harvested = []
    for src in sources.values():
        for r in src.routes:
            if r.stops:
                harvested.append((r.adjuster.adjuster_id,
                                  [s.claim.claim_id for s in r.stops]))

    for src_adj_id, order in harvested:
        for k, a in enumerate(adjusters):
            if not _eligible_for_all(a, k, order, node, by_id, miles):
                continue
            # the harvested order itself is always offered, so a
            # toggled source plan stays selectable card-for-card
            offer(a.adjuster_id,
                  _simulate(order, k, a, node, by_id, travel_min), order)
            if len(order) <= 11:
                cost, best_order = _optimal_sequence(
                    order, k, a, node, by_id, travel_min)
                if cost is not None:
                    offer(a.adjuster_id, cost, best_order)

    # Leave-one-out sub-routes: give the selection MILP fine-grained
    # pieces to recombine (whole routes from near-optimal runs overlap
    # too much to mix). Each subset is re-sequenced optimally.
    for (adj_id, cset), (_, order) in list(pool.items()):
        if len(order) < 2 or len(order) > 11:
            continue
        a, k = adj_of[adj_id], k_of[adj_id]
        for drop_cid in order:
            sub = [cid for cid in order if cid != drop_cid]
            cost, best_order = _optimal_sequence(sub, k, a, node, by_id,
                                                 travel_min)
            if cost is not None:
                offer(adj_id, cost, best_order)

    for c in claims:                      # single-claim fallback columns
        for k, a in enumerate(adjusters):
            i = node[c.claim_id]
            if config.is_eligible(a, c, miles[k][i]):
                cost = _simulate([c.claim_id], k, a, node, by_id,
                                 travel_min)
                offer(a.adjuster_id, cost, [c.claim_id])

    # ---- 3. Selection MILP -------------------------------------------
    engine = _pick_engine(engine)
    prob = pulp.LpProblem("route_selection", pulp.LpMinimize)
    y = {key: pulp.LpVariable(f"y_{i}", cat="Binary")
         for i, key in enumerate(pool)}
    d = {c.claim_id: pulp.LpVariable(f"d_{c.claim_id}", cat="Binary")
         for c in claims}
    prob += (pulp.lpSum(pool[key][0] * var for key, var in y.items())
             + pulp.lpSum(config.effective_penalty(c.priority, c.age_days)
                          * d[c.claim_id] for c in claims))
    for a in adjusters:
        cols = [var for key, var in y.items() if key[0] == a.adjuster_id]
        if cols:
            prob += pulp.lpSum(cols) <= 1
    for c in claims:
        covering = [var for key, var in y.items() if c.claim_id in key[1]]
        prob += pulp.lpSum(covering) + d[c.claim_id] == 1

    mip_t0 = time.time()
    if engine == "gurobi":
        try:
            prob.solve(pulp.GUROBI(msg=False, timeLimit=30, gapRel=0.0))
        except Exception:
            engine = "highs"
    if engine == "highs":
        prob.solve(pulp.HiGHS(msg=False, timeLimit=30, gapRel=0.0))
    proven = prob.sol_status == pulp.LpSolutionOptimal

    chosen = {}
    for key, var in y.items():
        if var.value() is not None and var.value() > 0.5:
            chosen[key[0]] = pool[key][1]
    sp_sol = _build_solution(chosen, adjusters, claims, miles, travel_min)
    if lunch_break or balance:
        # restamp times/endpoints with the lunch-and-span-aware timing
        # and report the same objective the toggled backends report
        for k, route in enumerate(sp_sol.routes):
            if not route.stops:
                continue
            order = [s.claim.claim_id for s in route.stops]
            timed = _seq._min_span_times(order, k, adjusters[k], node,
                                         by_id, travel_min,
                                         lunch_break=lunch_break)
            times, t_start, t_end = timed
            for stop, t in zip(route.stops, times):
                stop.arrival_min = t
                stop.departure_min = t + stop.claim.service_minutes
            route.start_min, route.end_min = t_start, t_end
        if balance:
            sp_sol.objective += config.BALANCE_COEFFICIENT * sum(
                r.end_min - r.start_min for r in sp_sol.routes
                if r.stops)

    # Safety net: the pool contains every source solution, so the MILP
    # can never legitimately do worse than the best of them.
    if sp_sol.objective > best_source.objective:
        sp_sol = best_source

    info = {
        "engine": engine,
        "selection_proven_optimal": proven,
        "pool_columns": len(pool),
        "ejected_cards": ejected[0],
        "toggles": {"lunch_break": lunch_break, "balance": balance},
        "sources": {name: s.objective for name, s in sources.items()},
        "best_source": best_source_name,
        "best_source_objective": best_source.objective,
        "objective": sp_sol.objective,
        "improvement_vs_best_source":
            best_source.objective - sp_sol.objective,
        "mip_seconds": round(time.time() - mip_t0, 2),
        "total_seconds": round(time.time() - t_start, 1),
    }
    return sp_sol, info