flight-alloc 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. flight_alloc-0.0.1.dist-info/METADATA +11 -0
  2. flight_alloc-0.0.1.dist-info/RECORD +71 -0
  3. flight_alloc-0.0.1.dist-info/WHEEL +5 -0
  4. flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
  5. flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/allocator/__init__.py +6 -0
  8. src/allocator/caps.py +513 -0
  9. src/allocator/eligibility.py +302 -0
  10. src/allocator/greedy_fallback.py +106 -0
  11. src/allocator/invariants.py +124 -0
  12. src/allocator/p2f_priority.py +142 -0
  13. src/allocator/pair_validation.py +108 -0
  14. src/allocator/pairings.py +554 -0
  15. src/allocator/postpass_break.py +366 -0
  16. src/allocator/postpass_intl.py +483 -0
  17. src/allocator/postpass_p2f.py +723 -0
  18. src/allocator/postpass_rebalance.py +244 -0
  19. src/allocator/postsolve.py +549 -0
  20. src/allocator/recommender.py +348 -0
  21. src/allocator/windows.py +377 -0
  22. src/cli.py +41 -0
  23. src/config.py +168 -0
  24. src/greedy_fallback.py +102 -0
  25. src/io/__init__.py +0 -0
  26. src/io/export.py +270 -0
  27. src/io/export_xml.py +66 -0
  28. src/io/readers.py +1048 -0
  29. src/io/roster_library.py +89 -0
  30. src/plan.py +192 -0
  31. src/recommender_staffing.py +329 -0
  32. src/roster_store.py +159 -0
  33. src/schemas.py +1244 -0
  34. src/solver/__init__.py +0 -0
  35. src/solver/allocator_cpsat.py +1412 -0
  36. src/staged_overrides.py +468 -0
  37. src/state.py +494 -0
  38. src/step1_clean_flights.py +286 -0
  39. src/step2_extract_roster.py +316 -0
  40. src/step3_allocate_flights.py +1639 -0
  41. src/web/__init__.py +47 -0
  42. src/web/__main__.py +9 -0
  43. src/web/api/__init__.py +56 -0
  44. src/web/api/export.py +37 -0
  45. src/web/api/inputs.py +122 -0
  46. src/web/api/override_rows.py +138 -0
  47. src/web/api/pages.py +30 -0
  48. src/web/api/readbacks.py +72 -0
  49. src/web/api/recommender.py +72 -0
  50. src/web/api/runs.py +102 -0
  51. src/web/api/settings.py +201 -0
  52. src/web/api/zc.py +117 -0
  53. src/web/core/__init__.py +5 -0
  54. src/web/core/responses.py +91 -0
  55. src/web/core/router.py +167 -0
  56. src/web/core/static_files.py +85 -0
  57. src/web/overrides/__init__.py +66 -0
  58. src/web/overrides/airports.py +261 -0
  59. src/web/overrides/break_time.py +83 -0
  60. src/web/overrides/config_yaml.py +21 -0
  61. src/web/overrides/filters.py +187 -0
  62. src/web/overrides/rows.py +110 -0
  63. src/web/readback/__init__.py +67 -0
  64. src/web/readback/common.py +68 -0
  65. src/web/readback/dashboard.py +83 -0
  66. src/web/readback/planning.py +335 -0
  67. src/web/readback/session.py +158 -0
  68. src/web/readback/tables.py +163 -0
  69. src/web/runner.py +168 -0
  70. src/web/server.py +185 -0
  71. src/zc_store.py +221 -0
@@ -0,0 +1,108 @@
1
+ """Pair-per-shift-change validation (user direction 2026-05-12, Task 3).
2
+
3
+ After pair generation, walk every (boundary, side, employee) triple and
4
+ flag the cases where a single person ends up with more than one distinct
5
+ partner at the same boundary. The only sanctioned exception is the
6
+ ``A_TO_N`` split-1+2 pattern, where one Night staff legitimately has two
7
+ A-shift partners (primary count=2, secondary count=1) — that's part of
8
+ the round-3 design, not a discrepancy.
9
+
10
+ Output is a list of ``WarningRow`` entries (severity=WARN, code=W215)
11
+ so the existing OUT_Warnings pipeline + dashboard panel surface them
12
+ without any new sheet. Each warning describes the conflict in plain
13
+ English so the assigner can review:
14
+
15
+ "Jai Sundrani is paired with both Deepak Kathiat and Joel Verghese
16
+ at the M→A boundary (3 partner(s) total). Review for exception."
17
+
18
+ The validator is purely diagnostic — it never blocks allocation.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections import defaultdict
24
+ from datetime import date as date_t
25
+
26
+ from ..schemas import OpsBoundary, Pair, Severity, WarningRow
27
+
28
+
29
+ def _is_a_to_n_split(pairs: list[Pair]) -> bool:
30
+ """Return True when this ``A_TO_N`` group is the sanctioned split-1+2
31
+ pattern: exactly 2 partners on the opposite side, preplan counts
32
+ summing to 3 with one entry =2 and the other =1. Other A→N
33
+ multi-partner shapes (e.g., 1+1+1 or 2+2) still flag as discrepancies.
34
+ """
35
+ if len(pairs) != 2:
36
+ return False
37
+ counts = sorted(p.preplan_count for p in pairs)
38
+ return counts == [1, 2]
39
+
40
+
41
+ def _format_partners(pairs: list[Pair], side: str) -> list[str]:
42
+ """``side`` = 'next' or 'prev' — the OPPOSITE side from the one
43
+ we're checking. Returns deduped, sorted display strings."""
44
+ seen: set[str] = set()
45
+ out: list[str] = []
46
+ for p in pairs:
47
+ emp_id = p.next_employee_id if side == "next" else p.prev_employee_id
48
+ name = p.next_name if side == "next" else p.prev_name
49
+ if emp_id is None:
50
+ continue
51
+ key = f"{emp_id}|{name or ''}"
52
+ if key in seen:
53
+ continue
54
+ seen.add(key)
55
+ out.append(name or emp_id)
56
+ out.sort()
57
+ return out
58
+
59
+
60
+ def validate_pair_discrepancies(
61
+ pairs: list[Pair], d_day: date_t,
62
+ ) -> list[WarningRow]:
63
+ """Identify staff who appear in multiple pairs at the same boundary.
64
+
65
+ Returns one WarningRow per flagged staff. Empty list when every
66
+ pair is 1-to-1 (or every multi-pair group is a sanctioned exception
67
+ such as A_TO_N split-1+2).
68
+ """
69
+ # Group by (boundary, "prev"/"next", employee_id). Each group is the
70
+ # list of pairs that mention this person on this side at this
71
+ # boundary. Multi-entry groups are candidates for a discrepancy
72
+ # warning.
73
+ by_role: dict[tuple[OpsBoundary, str, str, str], list[Pair]] = defaultdict(list)
74
+ for p in pairs:
75
+ if p.prev_employee_id is not None:
76
+ by_role[(p.boundary, "prev", p.prev_employee_id, p.prev_name or "")].append(p)
77
+ if p.next_employee_id is not None:
78
+ by_role[(p.boundary, "next", p.next_employee_id, p.next_name or "")].append(p)
79
+
80
+ out: list[WarningRow] = []
81
+ for (boundary, side, emp_id, emp_name), group in by_role.items():
82
+ if len(group) <= 1:
83
+ continue
84
+ # Sanctioned A→N split-1+2 exception.
85
+ if boundary == OpsBoundary.A_TO_N and _is_a_to_n_split(group):
86
+ continue
87
+ opposite_side = "next" if side == "prev" else "prev"
88
+ partners = _format_partners(group, opposite_side)
89
+ if len(partners) <= 1:
90
+ continue # All entries share the same partner — not a conflict.
91
+ out.append(WarningRow(
92
+ severity=Severity.WARN,
93
+ code="W215",
94
+ name=emp_name or emp_id,
95
+ date=d_day,
96
+ message=(
97
+ f"{emp_name or emp_id} is paired with {len(partners)} "
98
+ f"different partner(s) at the {boundary.value} boundary: "
99
+ f"{', '.join(partners)}. Review for exception."
100
+ ),
101
+ ))
102
+ # Stable order: by boundary then employee name for deterministic
103
+ # rendering in OUT_Warnings + the dashboard panel.
104
+ out.sort(key=lambda w: (str(w.code), str(w.name)))
105
+ return out
106
+
107
+
108
+ __all__ = ["validate_pair_discrepancies"]
@@ -0,0 +1,554 @@
1
+ """Pair generator for Step 4 (H8 + correction r2-7).
2
+
3
+ Auto-generates the full set of boundary pairs for an ops day, sequentially
4
+ by IN_Staff row order. ZCs only pair with ZCs at every boundary. The
5
+ A→N boundary uses a three-tier hierarchy when |A| > |N|.
6
+
7
+ Inputs:
8
+ staff_today — sorted by IN_Staff row order, already filtered to
9
+ assignable staff (off-day rows excluded by the
10
+ caller)
11
+ yesterday_n_staff — yesterday's N-shift roster, used for the N→M
12
+ boundary (D-1's N planned today's M flights). May
13
+ be None on the first run; in that case, today's M
14
+ staff self-plan their first flight.
15
+ overrides — pair-specific override rows; each one replaces
16
+ any auto-generated pair sharing the same (boundary,
17
+ prev_emp) or (boundary, next_emp).
18
+
19
+ Output: list[Pair]. Surplus staff with no partner are absent from the
20
+ output (emitted Pairs always have BOTH sides populated). The caller can
21
+ infer "this staff is unpaired at this boundary" by absence — useful for
22
+ the pair map's surplus annotations and for diagnosing under-staffing.
23
+
24
+ Three-tier hierarchy at A→N:
25
+ Tier 1: 1-to-1 primary A↔N up to min(|A|, |N|).
26
+ Tier 2: A surplus routes to A↔A1 handover-only.
27
+ Tier 3: Remaining A surplus routes to secondary A↔N handover-only,
28
+ one secondary per N staff (so each N person can have at most
29
+ one primary + one secondary A pair).
30
+ If A surplus still remains after tier 3, raises PairGenerationError —
31
+ the assigner has too many A staff for the day's structure and must
32
+ resolve via override or by reducing the A roster.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from collections import defaultdict
38
+
39
+ from ..schemas import OpsBoundary, Pair, PairRole, Role, ShiftCode, StaffMember
40
+
41
+ # H6 per-primary-pair pre-plan counts (defaults). Aligned with REF_Constraints.
42
+ # Per-pair count actually used at construction may differ from these defaults
43
+ # in special cases — currently the only such case is the split 1+2 at A→N
44
+ # when N has 2 A-partners (primary pair gets 2, secondary gets 1).
45
+ _H6_DEFAULT_COUNTS: dict[OpsBoundary, int] = {
46
+ OpsBoundary.N_TO_M: 1,
47
+ OpsBoundary.M_TO_M1: 1,
48
+ OpsBoundary.M_TO_A: 2,
49
+ OpsBoundary.M1_TO_A1: 2,
50
+ OpsBoundary.A_TO_N: 3,
51
+ OpsBoundary.A1_TO_N: 0,
52
+ OpsBoundary.A_TO_A1: 0,
53
+ }
54
+
55
+
56
+ class PairGenerationError(ValueError):
57
+ """Raised when A surplus exceeds the total handover-routing capacity
58
+ (A↔A1 + secondary A↔N). Maps to W211 in the warnings system."""
59
+
60
+
61
+ def _by_shift_role(
62
+ staff: list[StaffMember],
63
+ ) -> dict[tuple[ShiftCode, Role], list[StaffMember]]:
64
+ """Group staff by (shift_today, role), preserving input order (which
65
+ is the assigner's IN_Staff row order). Off-shift and AM staff are
66
+ dropped — they don't pair."""
67
+ out: dict[tuple[ShiftCode, Role], list[StaffMember]] = defaultdict(list)
68
+ for s in staff:
69
+ if s.shift_today is None or s.role == Role.AM:
70
+ continue
71
+ out[(s.shift_today, s.role)].append(s)
72
+ return out
73
+
74
+
75
+ def _make_pair(
76
+ prev: StaffMember, next_: StaffMember,
77
+ boundary: OpsBoundary, pair_role: PairRole,
78
+ prev_shift: ShiftCode, next_shift: ShiftCode,
79
+ *, preplan_count: int | None = None,
80
+ ) -> Pair:
81
+ """Build a Pair. ``preplan_count`` defaults to the H6 table value for
82
+ the boundary if not specified — pass an explicit override (e.g., 1
83
+ for split-secondary at A→N, 0 for handover-only)."""
84
+ if preplan_count is None:
85
+ preplan_count = (
86
+ _H6_DEFAULT_COUNTS[boundary]
87
+ if pair_role == PairRole.PRIMARY else 0
88
+ )
89
+ return Pair(
90
+ boundary=boundary, pair_role=pair_role, preplan_count=preplan_count,
91
+ prev_employee_id=prev.employee_id, prev_name=prev.name, prev_shift=prev_shift,
92
+ next_employee_id=next_.employee_id, next_name=next_.name, next_shift=next_shift,
93
+ )
94
+
95
+
96
+ def _one_to_one(
97
+ prev: list[StaffMember], next_: list[StaffMember],
98
+ boundary: OpsBoundary, pair_role: PairRole,
99
+ prev_shift: ShiftCode, next_shift: ShiftCode,
100
+ *, preplan_count: int | None = None,
101
+ ) -> tuple[list[Pair], list[StaffMember], list[StaffMember]]:
102
+ """Sequential pairing prev[i] ↔ next[i] up to min(|prev|, |next|).
103
+ Returns (pairs, prev_unpaired, next_unpaired)."""
104
+ n = min(len(prev), len(next_))
105
+ pairs = [
106
+ _make_pair(
107
+ prev[i], next_[i], boundary, pair_role, prev_shift, next_shift,
108
+ preplan_count=preplan_count,
109
+ )
110
+ for i in range(n)
111
+ ]
112
+ return pairs, prev[n:], next_[n:]
113
+
114
+
115
+ def _simple_boundary(
116
+ by_today: dict[tuple[ShiftCode, Role], list[StaffMember]],
117
+ boundary: OpsBoundary,
118
+ prev_shift: ShiftCode, next_shift: ShiftCode,
119
+ pair_role: PairRole,
120
+ ) -> list[Pair]:
121
+ """Pair regulars-with-regulars and ZCs-with-ZCs at a boundary that
122
+ doesn't use the 3-tier hierarchy. ZC isolation: a regular never
123
+ pairs with a ZC at this layer (the N→M cross-role exception is
124
+ handled separately in _n_to_m_cross_role)."""
125
+ pairs: list[Pair] = []
126
+ for role in (Role.STAFF, Role.ZC):
127
+ prev = by_today.get((prev_shift, role), [])
128
+ next_ = by_today.get((next_shift, role), [])
129
+ new_pairs, _, _ = _one_to_one(
130
+ prev, next_, boundary, pair_role, prev_shift, next_shift,
131
+ )
132
+ pairs.extend(new_pairs)
133
+ return pairs
134
+
135
+
136
+ def _n_to_m_cross_role(
137
+ by_yesterday: dict[tuple[ShiftCode, Role], list[StaffMember]],
138
+ by_today: dict[tuple[ShiftCode, Role], list[StaffMember]],
139
+ ) -> list[Pair]:
140
+ """Round-3 update: at the N→M boundary, M-ZCs that don't have an
141
+ N-ZC partner (because there's typically only 1 N-ZC vs 4 M-ZCs)
142
+ are planned by N **regular** staff. N names may repeat — the
143
+ priority is "every M-ZC's first flight gets a planner" over "no N
144
+ staff is reused" (per user direction).
145
+
146
+ Algorithm:
147
+ 1. Pair the N-ZCs to M-ZCs sequentially (same as existing ZC↔ZC).
148
+ 2. Remaining M-ZCs round-robin onto N-regulars (with repetition
149
+ when N-regular pool is smaller than the M-ZC remainder).
150
+ 3. ZC↔ZC pairs already emitted by _simple_boundary; this function
151
+ emits ONLY the cross-role overflow.
152
+ """
153
+ n_zcs = by_yesterday.get(("N", Role.ZC), [])
154
+ m_zcs = by_today.get(("M", Role.ZC), [])
155
+ n_regs = by_yesterday.get(("N", Role.STAFF), [])
156
+ m_zcs_unmatched = m_zcs[len(n_zcs):]
157
+ if not m_zcs_unmatched:
158
+ return []
159
+ if not n_regs:
160
+ # No N-regulars to plan from; the unmatched M-ZCs self-plan their
161
+ # first flight.
162
+ return []
163
+ pairs: list[Pair] = []
164
+ for i, m_zc in enumerate(m_zcs_unmatched):
165
+ n_planner = n_regs[i % len(n_regs)] # round-robin with repetition
166
+ pairs.append(_make_pair(
167
+ n_planner, m_zc, OpsBoundary.N_TO_M, PairRole.PRIMARY, "N", "M",
168
+ preplan_count=_H6_DEFAULT_COUNTS[OpsBoundary.N_TO_M],
169
+ ))
170
+ return pairs
171
+
172
+
173
+ def _three_tier_a_to_n(
174
+ a_regulars: list[StaffMember],
175
+ n_regulars: list[StaffMember],
176
+ a1_regulars: list[StaffMember],
177
+ ) -> list[Pair]:
178
+ """A→N regulars with the 3-tier hierarchy (correction r2-7 + split-1+2).
179
+
180
+ Tier 1: primary 1-to-1 A↔N (default preplan_count = 3 each).
181
+ Tier 2: A surplus → A↔A1 handover-only (preplan_count = 0).
182
+ Tier 3: A surplus still remaining → secondary A↔N as a SECOND
183
+ primary pair, but with split planning (round-3 update). The
184
+ primary pair's count drops from 3 to 2; the secondary pair
185
+ carries count 1. Sum still 3 per N. Each N person can have
186
+ at most 1 secondary pair.
187
+
188
+ Edge cases:
189
+ - No N regulars: A→N doesn't apply. A surplus may still go to A1
190
+ handover-only without raising.
191
+ - More A surplus than tier-3 capacity (|N|): raise
192
+ PairGenerationError (W211).
193
+ """
194
+ if not n_regulars:
195
+ if not a_regulars or not a1_regulars:
196
+ return []
197
+ tier2_only, _, _ = _one_to_one(
198
+ a_regulars, a1_regulars,
199
+ OpsBoundary.A_TO_A1, PairRole.HANDOVER_ONLY, "A", "A1",
200
+ preplan_count=0,
201
+ )
202
+ return tier2_only
203
+
204
+ # How many N's will get a split-secondary pair? It's the count of A
205
+ # staff still surplus after tiers 1 and 2.
206
+ a_used_tier1 = min(len(a_regulars), len(n_regulars))
207
+ a_after_tier1 = len(a_regulars) - a_used_tier1
208
+ a_used_tier2 = min(a_after_tier1, len(a1_regulars))
209
+ a_after_tier2 = a_after_tier1 - a_used_tier2
210
+ secondary_count = a_after_tier2
211
+
212
+ # 2026-05-28 (user direction — centralized softening): the
213
+ # capacity check WAS a hard raise (PairGenerationError) that
214
+ # aborted the entire allocation. In practice the unpaired tail is
215
+ # almost always 1–2 A staff out of 30+, which is operationally
216
+ # harmless (they just don't have a planned_by/relieved_by entry on
217
+ # their first/last flight — same as a pure single-shift staff).
218
+ # Aborting the whole run for 1 unpaired staff turned every "add
219
+ # one extra A-shift person" into a full allocation failure, which
220
+ # also masked downstream issues like add_staff / remove_staff not
221
+ # appearing. Now: cap secondary_count at capacity and let the
222
+ # surplus go unpaired without raising. The caller (step3) emits
223
+ # W211 INFO so the assigner still sees the count.
224
+ pairs_overflow_unpaired = max(0, secondary_count - len(n_regulars))
225
+ if pairs_overflow_unpaired > 0:
226
+ secondary_count = len(n_regulars)
227
+
228
+ pairs: list[Pair] = []
229
+ # Tier 1: per-N primary pair. The first ``secondary_count`` N's get
230
+ # primary preplan_count=2 (will be split with a secondary count=1);
231
+ # the remaining N's get the full 3.
232
+ for i in range(a_used_tier1):
233
+ primary_count = 2 if i < secondary_count else 3
234
+ pairs.append(_make_pair(
235
+ a_regulars[i], n_regulars[i],
236
+ OpsBoundary.A_TO_N, PairRole.PRIMARY, "A", "N",
237
+ preplan_count=primary_count,
238
+ ))
239
+ # Tier 2: A surplus → A↔A1 handover-only.
240
+ for j in range(a_used_tier2):
241
+ a_s = a_regulars[a_used_tier1 + j]
242
+ a1_s = a1_regulars[j]
243
+ pairs.append(_make_pair(
244
+ a_s, a1_s,
245
+ OpsBoundary.A_TO_A1, PairRole.HANDOVER_ONLY, "A", "A1",
246
+ preplan_count=0,
247
+ ))
248
+ # Tier 3: remaining A surplus → split-secondary A↔N (preplan_count=1).
249
+ # These pair with the FIRST `secondary_count` N's — same N's whose
250
+ # primary preplan_count was reduced to 2 above.
251
+ for k in range(secondary_count):
252
+ a_s = a_regulars[a_used_tier1 + a_used_tier2 + k]
253
+ n_s = n_regulars[k]
254
+ pairs.append(_make_pair(
255
+ a_s, n_s,
256
+ OpsBoundary.A_TO_N, PairRole.PRIMARY, "A", "N",
257
+ preplan_count=1,
258
+ ))
259
+ return pairs
260
+
261
+
262
+ def _apply_overrides(pairs: list[Pair], overrides: list[Pair]) -> list[Pair]:
263
+ """Replace auto-generated pairs that conflict with overrides.
264
+
265
+ An override "claims" any (boundary, prev_emp) and (boundary, next_emp)
266
+ slot it specifies. Auto-generated pairs sharing those slots are
267
+ dropped, leaving the override authoritative. Staff displaced by the
268
+ override become unpaired at that boundary (no pair row emitted).
269
+ """
270
+ claimed: set[tuple[OpsBoundary, str, str]] = set()
271
+ for o in overrides:
272
+ if o.prev_employee_id is not None:
273
+ claimed.add((o.boundary, "prev", o.prev_employee_id))
274
+ if o.next_employee_id is not None:
275
+ claimed.add((o.boundary, "next", o.next_employee_id))
276
+ out: list[Pair] = []
277
+ for p in pairs:
278
+ if p.prev_employee_id and (p.boundary, "prev", p.prev_employee_id) in claimed:
279
+ continue
280
+ if p.next_employee_id and (p.boundary, "next", p.next_employee_id) in claimed:
281
+ continue
282
+ out.append(p)
283
+ out.extend(overrides)
284
+ return out
285
+
286
+
287
+ def _hoist_p2f_first(
288
+ staff_list: list[StaffMember], p2f_id: str | None,
289
+ ) -> list[StaffMember]:
290
+ """Return the input list with the P2F nominee moved to the front.
291
+ Preserves order of every other member. No-op when ``p2f_id`` is
292
+ None / not in the list. Used by the v2 pair generator so the day's
293
+ P2F nominees naturally pair with each other via 1-to-1 sequencing.
294
+ """
295
+ if not p2f_id:
296
+ return list(staff_list)
297
+ p2f = [s for s in staff_list if s.employee_id == p2f_id]
298
+ rest = [s for s in staff_list if s.employee_id != p2f_id]
299
+ return p2f + rest
300
+
301
+
302
+ def _generate_pairs_v2(
303
+ staff_today: list[StaffMember],
304
+ yesterday_n_staff: list[StaffMember] | None,
305
+ overrides: list[Pair] | None,
306
+ p2f_handlers: dict[ShiftCode, str] | None,
307
+ ) -> list[Pair]:
308
+ """Alternate pair generator (user direction 2026-05-28 — Iteration B).
309
+
310
+ Differences from the default ``generate_pairs``:
311
+ * For each within-day P2F boundary (M→A, A→N), the day's P2F
312
+ nominees are hoisted to the FRONT of their shift's STAFF list
313
+ before 1-to-1 pairing. When both shifts have a nominee, they
314
+ pair with each other; when one side has no nominee, the other's
315
+ nominee falls back to whoever the normal logic would pair them
316
+ with.
317
+ * ZC pairing first absorbs as many ZC↔ZC matches as possible
318
+ across boundaries (including A↔A1 ZCs at the A_TO_A1 boundary,
319
+ which the default code only used for STAFF surplus). Whatever
320
+ ZCs remain orphan are folded into that side's STAFF pool for
321
+ the boundary's normal STAFF pairing — they "spill to STAFF".
322
+
323
+ Everything else (N→M cross-role exception, the 3-tier A→N STAFF
324
+ hierarchy, override application) matches the default generator.
325
+ """
326
+ by_today = _by_shift_role(staff_today)
327
+ p2f_handlers = p2f_handlers or {}
328
+ pairs: list[Pair] = []
329
+
330
+ # ---- ZC pairing pass (with overflow-to-STAFF) ----
331
+ # Two pools per shift: ``prev`` (ZCs available as boundary-prev,
332
+ # i.e. handing off OUT of this shift) and ``next`` (ZCs available
333
+ # as boundary-next, i.e. handing INTO this shift). A single ZC can
334
+ # legitimately appear in TWO pair rows in v1 (as next of an
335
+ # incoming boundary AND prev of an outgoing boundary) — that's the
336
+ # natural "handover bridge" pattern. Splitting prev / next
337
+ # preserves that pattern while still letting us detect ZCs that
338
+ # got zero pair entries (completely orphan).
339
+ zc_prev_pool: dict[ShiftCode, list[StaffMember]] = {
340
+ sh: list(by_today.get((sh, Role.ZC), []))
341
+ for sh in ("M", "M1", "A", "A1", "N")
342
+ }
343
+ zc_next_pool: dict[ShiftCode, list[StaffMember]] = {
344
+ sh: list(by_today.get((sh, Role.ZC), []))
345
+ for sh in ("M", "M1", "A", "A1", "N")
346
+ }
347
+ # Yesterday's N ZCs only ever play prev (handing INTO today's M).
348
+ by_yesterday = _by_shift_role(yesterday_n_staff) if yesterday_n_staff else {}
349
+ yesterday_n_zc_prev = list(by_yesterday.get(("N", Role.ZC), []))
350
+ # Track which ZCs participated in any pair so we can fold the
351
+ # completely-unpaired set into STAFF later.
352
+ paired_zc_ids: set[str] = set()
353
+
354
+ def _zc_pair_dir(
355
+ prev_pool: list[StaffMember],
356
+ next_pool: list[StaffMember],
357
+ boundary: OpsBoundary,
358
+ pair_role: PairRole,
359
+ prev_shift: ShiftCode, next_shift: ShiftCode,
360
+ *, preplan_count: int | None = None,
361
+ ) -> None:
362
+ """Pair as many ZCs as possible 1-to-1 at this boundary.
363
+
364
+ 2026-05-28 (centralized fix): drain ONLY the prev pool. Each ZC
365
+ plays at most one "prev" role (outgoing handover) across the
366
+ whole day — that's what the user's "rest left zc (here 2)"
367
+ math requires. But the "next" role (incoming handover) can be
368
+ filled multiple times — A1's only ZC legitimately receives
369
+ briefings from BOTH M1 (start-of-shift, M1→A1) AND A (mid-shift,
370
+ A→A1), so draining A1's next pool at M1→A1 would zero-out the
371
+ A→A1 ZC pair the user explicitly asked for. v1's behavior is
372
+ consistent with this — N's ZC was "next" at both A→N and
373
+ A1→N without conflict.
374
+ """
375
+ n = min(len(prev_pool), len(next_pool))
376
+ if n <= 0:
377
+ return
378
+ for i in range(n):
379
+ pairs.append(_make_pair(
380
+ prev_pool[i], next_pool[i],
381
+ boundary, pair_role, prev_shift, next_shift,
382
+ preplan_count=preplan_count,
383
+ ))
384
+ paired_zc_ids.add(prev_pool[i].employee_id)
385
+ paired_zc_ids.add(next_pool[i].employee_id)
386
+ # Drain ONLY prev (outgoing handover). Leave next pool intact
387
+ # so a ZC on the receiving side can absorb multiple incoming
388
+ # handovers across boundaries.
389
+ del prev_pool[:n]
390
+
391
+ # ZC pairing order: same set of boundaries as v1 + the new A↔A1 ZC
392
+ # handover. Drain is DIRECTIONAL (prev vs next), so a single ZC can
393
+ # still appear in multiple pair rows when they bridge boundaries.
394
+ if yesterday_n_staff:
395
+ _zc_pair_dir(
396
+ yesterday_n_zc_prev, zc_next_pool["M"],
397
+ OpsBoundary.N_TO_M, PairRole.PRIMARY, "N", "M",
398
+ )
399
+ _zc_pair_dir(
400
+ zc_prev_pool["M"], zc_next_pool["M1"],
401
+ OpsBoundary.M_TO_M1, PairRole.PRIMARY, "M", "M1",
402
+ )
403
+ _zc_pair_dir(
404
+ zc_prev_pool["M"], zc_next_pool["A"],
405
+ OpsBoundary.M_TO_A, PairRole.PRIMARY, "M", "A",
406
+ )
407
+ _zc_pair_dir(
408
+ zc_prev_pool["M1"], zc_next_pool["A1"],
409
+ OpsBoundary.M1_TO_A1, PairRole.PRIMARY, "M1", "A1",
410
+ )
411
+ _zc_pair_dir(
412
+ zc_prev_pool["A"], zc_next_pool["N"],
413
+ OpsBoundary.A_TO_N, PairRole.PRIMARY, "A", "N",
414
+ )
415
+ # 2026-05-28 NEW: A↔A1 ZC handover. The default v1 code only paired
416
+ # STAFF at this boundary; here we catch surplus A-ZCs (prev side)
417
+ # against any unused A1-ZC (next side) before they spill to STAFF.
418
+ _zc_pair_dir(
419
+ zc_prev_pool["A"], zc_next_pool["A1"],
420
+ OpsBoundary.A_TO_A1, PairRole.HANDOVER_ONLY, "A", "A1",
421
+ preplan_count=0,
422
+ )
423
+ _zc_pair_dir(
424
+ zc_prev_pool["A1"], zc_next_pool["N"],
425
+ OpsBoundary.A1_TO_N, PairRole.HANDOVER_ONLY, "A1", "N",
426
+ preplan_count=0,
427
+ )
428
+
429
+ # ---- ZC overflow → STAFF augmentation ----
430
+ # Completely-unpaired ZCs (zero pair entries in EITHER prev or next
431
+ # role across all boundaries) get folded into their shift's STAFF
432
+ # list so the STAFF pair-generation logic below treats them as
433
+ # regular staff for pairing only. Their role / workload caps stay
434
+ # ZC — only their pair slot changes.
435
+ staff_lists: dict[ShiftCode, list[StaffMember]] = {
436
+ sh: list(by_today.get((sh, Role.STAFF), []))
437
+ for sh in ("M", "M1", "A", "A1", "N")
438
+ }
439
+ for sh in ("M", "M1", "A", "A1", "N"):
440
+ for zc in by_today.get((sh, Role.ZC), []):
441
+ if zc.employee_id not in paired_zc_ids:
442
+ staff_lists[sh].append(zc)
443
+ # Yesterday's N ZCs that didn't pair into today's M (rare):
444
+ yesterday_n_orphans = [
445
+ zc for zc in by_yesterday.get(("N", Role.ZC), [])
446
+ if zc.employee_id not in paired_zc_ids
447
+ ] if yesterday_n_staff else []
448
+ yesterday_n_staff_aug = (
449
+ list(by_yesterday.get(("N", Role.STAFF), [])) + yesterday_n_orphans
450
+ if yesterday_n_staff else []
451
+ )
452
+
453
+ # ---- P2F nominee hoisting on STAFF lists ----
454
+ # Move each shift's P2F nominee to the front of its augmented STAFF
455
+ # list so the 1-to-1 sequencing pairs nominees with each other at
456
+ # M→A and A→N. M / A / N are the only P2F-nominating shifts.
457
+ for sh in ("M", "A", "N"):
458
+ staff_lists[sh] = _hoist_p2f_first(
459
+ staff_lists[sh], p2f_handlers.get(sh),
460
+ )
461
+
462
+ # ---- STAFF pairing using the augmented lists ----
463
+ # N → M cross-day (with cross-role exception unchanged).
464
+ if yesterday_n_staff:
465
+ n2m_pairs, _, _ = _one_to_one(
466
+ yesterday_n_staff_aug, staff_lists["M"],
467
+ OpsBoundary.N_TO_M, PairRole.PRIMARY, "N", "M",
468
+ )
469
+ pairs.extend(n2m_pairs)
470
+ # Cross-role exception (preserved): unmatched M-ZCs (those
471
+ # already in zc_pool["M"] before we folded them into STAFF
472
+ # would have stayed orphan — but here they were already folded.
473
+ # So just call the existing helper against the original
474
+ # un-augmented yesterday's view, mirroring the default code.
475
+ pairs.extend(_n_to_m_cross_role(by_yesterday, by_today))
476
+
477
+ # M→M1
478
+ m_to_m1_pairs, _, _ = _one_to_one(
479
+ staff_lists["M"], staff_lists["M1"],
480
+ OpsBoundary.M_TO_M1, PairRole.PRIMARY, "M", "M1",
481
+ )
482
+ pairs.extend(m_to_m1_pairs)
483
+
484
+ # M→A
485
+ m_to_a_pairs, _, _ = _one_to_one(
486
+ staff_lists["M"], staff_lists["A"],
487
+ OpsBoundary.M_TO_A, PairRole.PRIMARY, "M", "A",
488
+ )
489
+ pairs.extend(m_to_a_pairs)
490
+
491
+ # M1→A1
492
+ m1_to_a1_pairs, _, _ = _one_to_one(
493
+ staff_lists["M1"], staff_lists["A1"],
494
+ OpsBoundary.M1_TO_A1, PairRole.PRIMARY, "M1", "A1",
495
+ )
496
+ pairs.extend(m1_to_a1_pairs)
497
+
498
+ # A→N: keep the 3-tier hierarchy for STAFF (orphan ZCs folded in
499
+ # above appear at the END of the A-STAFF list, so primary pairs
500
+ # still consume "real" STAFF first and any ZC overflow goes to
501
+ # tier-2/tier-3 — which is exactly the user-intended fallback).
502
+ pairs.extend(_three_tier_a_to_n(
503
+ staff_lists["A"], staff_lists["N"], staff_lists["A1"],
504
+ ))
505
+
506
+ # A1→N handover-only
507
+ a1_to_n_pairs, _, _ = _one_to_one(
508
+ staff_lists["A1"], staff_lists["N"],
509
+ OpsBoundary.A1_TO_N, PairRole.HANDOVER_ONLY, "A1", "N",
510
+ preplan_count=0,
511
+ )
512
+ pairs.extend(a1_to_n_pairs)
513
+
514
+ if overrides:
515
+ pairs = _apply_overrides(pairs, overrides)
516
+ return pairs
517
+
518
+
519
+ def generate_pairs(
520
+ staff_today: list[StaffMember],
521
+ *,
522
+ yesterday_n_staff: list[StaffMember] | None = None,
523
+ overrides: list[Pair] | None = None,
524
+ p2f_handlers: dict[ShiftCode, str] | None = None,
525
+ ) -> list[Pair]:
526
+ """Build the full pair list for an ops day. See module docstring for
527
+ the algorithm, invariants, and error conditions.
528
+
529
+ 2026-05-28 (Phase 2 promoted to default — user direction): single
530
+ centralized path. The previous v1 generator and the v2 variant are
531
+ no longer two parallel paths; the v2 logic (P2F-prefer hoisting,
532
+ ZC-only with STAFF overflow, A↔A1 ZC pairing) is now the only path.
533
+
534
+ Why this is safe:
535
+ * Empty ``p2f_handlers`` → P2F-hoist is identity (same staff
536
+ order as v1).
537
+ * Balanced ZC counts → no orphan ZCs, no STAFF augmentation
538
+ happens (same STAFF pool as v1).
539
+ * A↔A1 ZC pairing only emits when BOTH have spare ZCs; otherwise
540
+ zero-op. (v1 had zero pairs at A_TO_A1 for ZCs — so any new
541
+ pair here is purely additive, never replaces a v1 pair.)
542
+ * ZC drain semantics: a single ZC pairs at most once across all
543
+ boundaries (in v1 a ZC could appear in two boundaries — once
544
+ as ``next`` and once as ``prev``). User direction 2026-05-28
545
+ requires drain so the "rest left zc (here 2)" overflow math
546
+ works for the example A=4 ZC, N=1 ZC, A1=1 ZC.
547
+
548
+ The ``p2f_adjustment.logic_v2`` config flag continues to govern the
549
+ P2F POST-PASS (anchored v1 vs. union-window v2) and the post-pass
550
+ order swap. Pair generation is no longer behind that flag.
551
+ """
552
+ return _generate_pairs_v2(
553
+ staff_today, yesterday_n_staff, overrides, p2f_handlers,
554
+ )