codex-services 0.1.0__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.
@@ -0,0 +1,171 @@
1
+ """
2
+ codex_services.booking.slot_master.api
3
+ ======================================
4
+ High-level facade for the slot-master booking engine.
5
+
6
+ Use this module if you work with plain Python dicts and don't need ORM integration.
7
+ For Django projects, see :mod:`codex_tools.adapters.django`.
8
+
9
+ Quick start::
10
+
11
+ from codex_services.booking.slot_master.api import find_slots
12
+
13
+ result = find_slots(
14
+ request_data={
15
+ "service_requests": [
16
+ {
17
+ "service_id": "haircut",
18
+ "duration_minutes": 60,
19
+ "possible_resource_ids": ["1", "2"],
20
+ }
21
+ ],
22
+ "booking_date": "2024-05-15",
23
+ },
24
+ resources_availability=[
25
+ {
26
+ "resource_id": "1",
27
+ "free_windows": [
28
+ ["2024-05-15T09:00:00", "2024-05-15T18:00:00"],
29
+ ],
30
+ }
31
+ ],
32
+ )
33
+ # result["solutions"] — list of booking chain dicts
34
+ # result["solutions"][0]["items"][0]["start_time"] — ISO datetime string
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from collections.abc import Callable
40
+ from datetime import date
41
+ from typing import Any
42
+
43
+ from .chain_finder import ChainFinder
44
+ from .dto import BookingEngineRequest, EngineResult, MasterAvailability
45
+ from .scorer import BookingScorer, ScoringWeights
46
+
47
+
48
+ def _parse_availability(items: list[dict[str, Any]]) -> dict[str, MasterAvailability]:
49
+ return {(m := MasterAvailability.model_validate(item)).resource_id: m for item in items}
50
+
51
+
52
+ def find_slots(
53
+ request_data: dict[str, Any],
54
+ resources_availability: list[dict[str, Any]],
55
+ *,
56
+ step_minutes: int = 30,
57
+ max_solutions: int = 50,
58
+ scoring_weights: dict[str, Any] | None = None,
59
+ preferred_resource_ids: list[str] | None = None,
60
+ ) -> dict[str, Any]:
61
+ """Find available booking slots for one day.
62
+
63
+ Validates input via Pydantic, runs :class:`ChainFinder`, optionally scores results.
64
+
65
+ Args:
66
+ request_data: Dict matching :class:`~codex_services.booking.slot_master.BookingEngineRequest`.
67
+ Required: ``service_requests``, ``booking_date``.
68
+ resources_availability: List of dicts matching
69
+ :class:`~codex_services.booking.slot_master.MasterAvailability`.
70
+ Each item must contain ``resource_id`` and ``free_windows``.
71
+ step_minutes: Slot grid step in minutes. Defaults to 30.
72
+ max_solutions: Max solutions returned by the engine. Defaults to 50.
73
+ scoring_weights: Optional dict with :class:`~codex_services.booking.slot_master.scorer.ScoringWeights`
74
+ fields to rank solutions. Supported keys: ``preferred_resource_bonus``,
75
+ ``same_resource_bonus``, ``min_idle_bonus_per_hour``,
76
+ ``early_slot_penalty_per_hour``.
77
+ preferred_resource_ids: Resource IDs to boost in scoring.
78
+ Only effective when ``scoring_weights`` is provided.
79
+
80
+ Returns:
81
+ Serialised :class:`~codex_services.booking.slot_master.EngineResult` dict.
82
+ Key ``"solutions"`` is a list of booking chain dicts.
83
+ Returns an empty list under ``"solutions"`` if no slots are available.
84
+
85
+ Raises:
86
+ pydantic.ValidationError: If ``request_data`` or ``resources_availability``
87
+ do not match the expected schema.
88
+ NotImplementedError: If the request uses an unsupported mode
89
+ (e.g. MULTI_DAY or group bookings).
90
+
91
+ Example::
92
+
93
+ result = find_slots(
94
+ request_data={
95
+ "service_requests": [
96
+ {"service_id": "s1", "duration_minutes": 60,
97
+ "possible_resource_ids": ["m1"]},
98
+ ],
99
+ "booking_date": "2024-05-15",
100
+ },
101
+ resources_availability=[
102
+ {"resource_id": "m1",
103
+ "free_windows": [["2024-05-15T09:00:00", "2024-05-15T18:00:00"]]},
104
+ ],
105
+ )
106
+ for chain in result["solutions"]:
107
+ print(chain["items"][0]["start_time"])
108
+ """
109
+ request = BookingEngineRequest.model_validate(request_data)
110
+ availability = _parse_availability(resources_availability)
111
+
112
+ result: EngineResult = ChainFinder(step_minutes).find(request, availability, max_solutions=max_solutions)
113
+
114
+ if scoring_weights is not None:
115
+ weights = ScoringWeights(**scoring_weights)
116
+ scorer = BookingScorer(weights=weights, preferred_resource_ids=preferred_resource_ids)
117
+ result = scorer.score(result)
118
+
119
+ return result.model_dump(mode="json")
120
+
121
+
122
+ def find_nearest_slots(
123
+ request_data: dict[str, Any],
124
+ get_availability_fn: Callable[[date], list[dict[str, Any]]],
125
+ *,
126
+ search_from: date | None = None,
127
+ search_days: int = 60,
128
+ step_minutes: int = 30,
129
+ ) -> dict[str, Any] | None:
130
+ """Search for the nearest available booking slots across multiple days.
131
+
132
+ Iterates days starting from *search_from* until a day with solutions is found.
133
+
134
+ Args:
135
+ request_data: Dict matching :class:`~codex_services.booking.slot_master.BookingEngineRequest`.
136
+ get_availability_fn: Callable ``(date) -> list[dict]``. Called for each checked
137
+ day. Each dict in the returned list must match
138
+ :class:`~codex_services.booking.slot_master.MasterAvailability`.
139
+ search_from: Start date (inclusive). Defaults to :func:`~datetime.date.today`.
140
+ search_days: Max number of days to search. Defaults to 60.
141
+ step_minutes: Slot grid step in minutes. Defaults to 30.
142
+
143
+ Returns:
144
+ Serialised :class:`~codex_services.booking.slot_master.EngineResult` dict for the first
145
+ matching day, or ``None`` if no slots were found within *search_days*.
146
+
147
+ Example::
148
+
149
+ def get_availability(d):
150
+ # return your per-day availability data as list of dicts
151
+ return [...]
152
+
153
+ result = find_nearest_slots(
154
+ request_data={...},
155
+ get_availability_fn=get_availability,
156
+ search_from=date.today(),
157
+ )
158
+ if result:
159
+ print(result["solutions"][0]["items"][0]["start_time"])
160
+ """
161
+ search_from = search_from or date.today()
162
+ request = BookingEngineRequest.model_validate(request_data)
163
+
164
+ def _get_avail(d: date) -> dict[str, MasterAvailability]:
165
+ return _parse_availability(get_availability_fn(d))
166
+
167
+ result = ChainFinder(step_minutes).find_nearest(
168
+ request, _get_avail, search_from=search_from, search_days=search_days
169
+ )
170
+
171
+ return result.model_dump(mode="json") if result.has_solutions else None
@@ -0,0 +1,450 @@
1
+ """
2
+ codex_services.booking.slot_master.chain_finder
3
+ ================================================
4
+ Main algorithm for finding slot chains for N services.
5
+
6
+ Framework-agnostic (does not depend on Django/ORM) -- works only with DTOs and datetime.
7
+ Acts as an orchestrator for SlotCalculator and BookingValidator.
8
+
9
+ Performance principle:
10
+ Inside recursive search (backtracking) we use lightweight _SlotCandidate
11
+ with __slots__ -- without Pydantic validation. Pydantic objects (SingleServiceSolution,
12
+ BookingChainSolution) are created ONLY once for each found solution.
13
+ This is critical with a large number of services and resources.
14
+
15
+ Imports:
16
+ from codex_services.booking.slot_master import ChainFinder
17
+ """
18
+
19
+ from collections.abc import Callable
20
+ from datetime import date, datetime, timedelta
21
+
22
+ from codex_services.booking._shared.calculator import SlotCalculator
23
+
24
+ from .dto import (
25
+ BookingChainSolution,
26
+ BookingEngineRequest,
27
+ EngineResult,
28
+ MasterAvailability,
29
+ SingleServiceSolution,
30
+ )
31
+ from .modes import BookingMode
32
+
33
+
34
+ class _SlotCandidate:
35
+ """
36
+ Lightweight internal representation of a booked slot inside backtracking.
37
+
38
+ Uses __slots__ for minimal memory consumption and fast access.
39
+ Does NOT contain Pydantic validation -- only raw data.
40
+
41
+ Converted to SingleServiceSolution (Pydantic) only for the final result.
42
+ """
43
+
44
+ __slots__ = (
45
+ "service_id",
46
+ "resource_id",
47
+ "start_time",
48
+ "end_time",
49
+ "gap_end_time",
50
+ "parallel_group",
51
+ )
52
+
53
+ def __init__(
54
+ self,
55
+ service_id: str,
56
+ resource_id: str,
57
+ start_time: datetime,
58
+ end_time: datetime,
59
+ gap_end_time: datetime,
60
+ parallel_group: str | None = None,
61
+ ) -> None:
62
+ self.service_id = service_id
63
+ self.resource_id = resource_id
64
+ self.start_time = start_time
65
+ self.end_time = end_time
66
+ self.gap_end_time = gap_end_time
67
+ self.parallel_group = parallel_group
68
+
69
+ def to_solution(self) -> SingleServiceSolution:
70
+ """Converts to Pydantic DTO for the final result."""
71
+ return SingleServiceSolution(
72
+ service_id=self.service_id,
73
+ resource_id=self.resource_id,
74
+ start_time=self.start_time,
75
+ end_time=self.end_time,
76
+ gap_end_time=self.gap_end_time,
77
+ )
78
+
79
+
80
+ class ChainFinder:
81
+ """
82
+ Finds combinations of time slots for N services (booking chains).
83
+
84
+ Core algorithm: recursive backtracking.
85
+ Iterates over possible resources and their free windows for each service.
86
+ Checks for the absence of conflicts with already assigned services in the chain.
87
+
88
+ Examples:
89
+ 1 service, any free resource:
90
+ ```python
91
+ finder = ChainFinder(step_minutes=30)
92
+ request = BookingEngineRequest(
93
+ service_requests=[
94
+ ServiceRequest(service_id="5", duration_minutes=60,
95
+ possible_resource_ids=["1", "2"])
96
+ ],
97
+ booking_date=date(2024, 5, 10),
98
+ mode=BookingMode.SINGLE_DAY,
99
+ )
100
+ result = finder.find(request, resources_availability)
101
+ # result.solutions -- list of BookingChainSolution
102
+ # result.get_unique_start_times() -> ["09:00", "09:30", "10:00", ...]
103
+ ```
104
+
105
+ 2 services on the same day:
106
+ ```python
107
+ request = BookingEngineRequest(
108
+ service_requests=[svc_task_1, svc_task_2],
109
+ booking_date=date(2024, 5, 10),
110
+ mode=BookingMode.SINGLE_DAY,
111
+ )
112
+ result = finder.find(request, availability)
113
+ ```
114
+
115
+ Booking a specific resource (RESOURCE_LOCKED):
116
+ ```python
117
+ # Just pass possible_resource_ids=[locked_resource_id]
118
+ # and mode=BookingMode.RESOURCE_LOCKED
119
+ ```
120
+
121
+ Args:
122
+ step_minutes: Grid slot step (30 min by default).
123
+ min_start: Minimum acceptable start time of the first service.
124
+ None = no restriction (e.g., in tests).
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ step_minutes: int = 30,
130
+ min_start: datetime | None = None,
131
+ ) -> None:
132
+ self.step_minutes = step_minutes
133
+ self.min_start = min_start
134
+ self._calc = SlotCalculator(step_minutes)
135
+
136
+ def find(
137
+ self,
138
+ request: BookingEngineRequest,
139
+ resources_availability: dict[str, MasterAvailability],
140
+ max_solutions: int = 50,
141
+ max_unique_starts: int | None = None,
142
+ ) -> EngineResult:
143
+ """
144
+ Unified engine entry point. Delegates to the appropriate mode.
145
+
146
+ Args:
147
+ request: Input request with services, date, and mode.
148
+ resources_availability: Dictionary {resource_id: MasterAvailability}.
149
+ Usually prepared by an AvailabilityAdapter.
150
+ Keys -- strings (resource identifiers).
151
+ max_solutions: Maximum number of options the engine will return.
152
+ Does not affect correctness -- only completeness.
153
+ max_unique_starts: Stop after finding N unique start times
154
+ (based on items[0].start_time).
155
+ None = no limit.
156
+ Example: max_unique_starts=8 -> only the closest 8 slots,
157
+ even if there are 16 in a day. Halves engine iterations.
158
+
159
+ Returns:
160
+ EngineResult with found solutions.
161
+ solutions sorted by the start time of the first service.
162
+ """
163
+ # Fail Fast: Check for unsupported features
164
+ if request.group_size > 1:
165
+ raise NotImplementedError("Group bookings (group_size > 1) are not yet supported.")
166
+
167
+ if request.mode == BookingMode.MULTI_DAY:
168
+ raise NotImplementedError("MULTI_DAY booking mode is not yet implemented.")
169
+
170
+ if request.mode in (BookingMode.SINGLE_DAY, BookingMode.RESOURCE_LOCKED):
171
+ solutions = self._find_single_day(request, resources_availability, max_solutions, max_unique_starts)
172
+ else:
173
+ solutions = [] # pragma: no cover
174
+
175
+ solutions.sort(key=lambda s: s.starts_at)
176
+ return EngineResult(mode=request.mode, solutions=solutions)
177
+
178
+ def find_nearest(
179
+ self,
180
+ request: BookingEngineRequest,
181
+ get_availability_for_date: Callable[[date], dict[str, MasterAvailability]],
182
+ search_from: date,
183
+ search_days: int = 60,
184
+ max_solutions_per_day: int = 1,
185
+ ) -> EngineResult:
186
+ """
187
+ Searches for the first day with available slots in the search_days range.
188
+
189
+ Used for:
190
+ - Rebooking: resource is sick -> find a new date for N appointments
191
+ - Waitlist: closest free slot to notify the client
192
+ - MULTI_DAY planning: find the first day the chain fits
193
+
194
+ Args:
195
+ request: Request (booking_date will be replaced for each checked day).
196
+ get_availability_for_date: callable(date) -> dict[str, MasterAvailability].
197
+ Called for each checked day.
198
+ Wraps DjangoAvailabilityAdapter in the Django layer.
199
+ search_from: Date to start the search from (inclusive).
200
+ search_days: Maximum days to check. Defaults to 60.
201
+ max_solutions_per_day: How many solutions to look for per day.
202
+ 1 = fast mode (stop at the first one).
203
+
204
+ Returns:
205
+ EngineResult of the first day with solutions.
206
+ If nothing found in search_days — EngineResult(solutions=[]).
207
+
208
+ Example (Django layer):
209
+ adapter = DjangoAvailabilityAdapter()
210
+ resource_ids = [...]
211
+
212
+ def get_avail(d):
213
+ return adapter.build_resources_availability(resource_ids, d)
214
+
215
+ result = finder.find_nearest(request, get_avail, search_from=date.today())
216
+ if result.has_solutions:
217
+ print(result.best.starts_at) # date and time of the new slot
218
+ """
219
+ for offset in range(search_days):
220
+ check_date = search_from + timedelta(days=offset)
221
+
222
+ # Update date in the request (frozen=True -> model_copy)
223
+ day_request = request.model_copy(update={"booking_date": check_date})
224
+
225
+ availability = get_availability_for_date(check_date)
226
+ if not availability:
227
+ continue
228
+
229
+ result = self.find(day_request, availability, max_solutions=max_solutions_per_day)
230
+ if result.has_solutions:
231
+ return result
232
+
233
+ return EngineResult(mode=request.mode, solutions=[])
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # SINGLE_DAY / RESOURCE_LOCKED mode
237
+ # ---------------------------------------------------------------------------
238
+
239
+ def _find_single_day(
240
+ self,
241
+ request: BookingEngineRequest,
242
+ resources_availability: dict[str, MasterAvailability],
243
+ max_solutions: int,
244
+ max_unique_starts: int | None = None,
245
+ ) -> list[BookingChainSolution]:
246
+ """
247
+ Search for a chain for the 'all services in one day' mode.
248
+
249
+ Performance:
250
+ Internally works with _SlotCandidate (__slots__) -- without Pydantic.
251
+ Pydantic objects are created ONLY for final solutions.
252
+ With 3 services x 3 resources x 20 slots -- up to 1800 iterations.
253
+
254
+ Request parameters considered in this method:
255
+ request.overlap_allowed:
256
+ True -> different resources work independently (can be parallel).
257
+ False -> each subsequent service starts only after the previous one ends.
258
+ request.max_chain_duration_minutes:
259
+ If set — cuts off backtracking branches where the chain already exceeds the limit.
260
+ """
261
+ solutions: list[BookingChainSolution] = []
262
+ chain: list[_SlotCandidate] = []
263
+ seen_starts: set[str] = set() # unique start times of the first service
264
+
265
+ def backtrack(service_index: int) -> None:
266
+ if len(solutions) >= max_solutions:
267
+ return
268
+ if max_unique_starts is not None and len(seen_starts) >= max_unique_starts:
269
+ return
270
+
271
+ if service_index >= len(request.service_requests):
272
+ # Final check + conversion to Pydantic only here
273
+ if self._no_conflicts_fast(chain):
274
+ solution = BookingChainSolution(items=[c.to_solution() for c in chain])
275
+ solutions.append(solution)
276
+ seen_starts.add(chain[0].start_time.strftime("%H:%M"))
277
+ return
278
+
279
+ service_req = request.service_requests[service_index]
280
+ duration_delta = timedelta(minutes=service_req.duration_minutes)
281
+ gap_delta = timedelta(minutes=service_req.min_gap_after_minutes)
282
+
283
+ # --- Parallel Group Logic ---
284
+ # If the current service has a parallel_group, look for a "partner" in the already assembled chain.
285
+ # If found, the start time must strictly match.
286
+ forced_start_time: datetime | None = None
287
+ if service_req.parallel_group:
288
+ for item in chain:
289
+ if item.parallel_group == service_req.parallel_group:
290
+ forced_start_time = item.start_time
291
+ break
292
+
293
+ for resource_id in service_req.possible_resource_ids:
294
+ availability = resources_availability.get(resource_id)
295
+ if not availability:
296
+ continue
297
+
298
+ # Busy intervals of this resource in the current chain
299
+ resource_busy = [(c.start_time, c.gap_end_time) for c in chain if c.resource_id == resource_id]
300
+
301
+ # If there is a forced_start_time, check only it
302
+ if forced_start_time:
303
+ # Check if the resource is free at this specific time
304
+ slot_end = forced_start_time + duration_delta
305
+ gap_end = slot_end + gap_delta
306
+
307
+ # 1. Check resource's occupancy in the chain
308
+ if not self._is_slot_free_fast(forced_start_time, gap_end, resource_busy):
309
+ continue
310
+
311
+ # 2. Check if it falls into the resource's free windows
312
+ in_window = False
313
+ for w_start, w_end in availability.free_windows:
314
+ if w_start <= forced_start_time and w_end >= slot_end:
315
+ in_window = True
316
+ break
317
+
318
+ if not in_window:
319
+ continue
320
+
321
+ # If everything is ok - add and move on
322
+ chain.append(
323
+ _SlotCandidate(
324
+ service_id=service_req.service_id,
325
+ resource_id=resource_id,
326
+ start_time=forced_start_time,
327
+ end_time=slot_end,
328
+ gap_end_time=gap_end,
329
+ parallel_group=service_req.parallel_group,
330
+ )
331
+ )
332
+ backtrack(service_index + 1)
333
+ chain.pop()
334
+ continue # Move to the next resource, no need to iterate over slots
335
+
336
+ # --- Standard Logic (No forced start time) ---
337
+ effective_min = self._effective_min_start(resource_busy, availability.buffer_between_minutes)
338
+
339
+ # overlap_allowed=False: each service starts after the end of all previous ones
340
+ if not request.overlap_allowed and chain:
341
+ chain_ends_at = max(c.end_time for c in chain)
342
+ if effective_min is None or effective_min < chain_ends_at:
343
+ effective_min = chain_ends_at
344
+
345
+ # --- Anchor Logic ---
346
+ # Use anchor only for the first service or if services are parallel.
347
+ # For 2nd+ service in a sequential chain, anchor is None (tight fit).
348
+ use_anchor = availability.work_start if (not chain or request.overlap_allowed) else None
349
+
350
+ for window_start, window_end in availability.free_windows:
351
+ slots = self._calc.find_slots_in_window(
352
+ window_start=window_start,
353
+ window_end=window_end,
354
+ duration_minutes=service_req.duration_minutes,
355
+ min_start=effective_min,
356
+ grid_anchor=use_anchor,
357
+ )
358
+
359
+ for slot_start in slots:
360
+ if len(solutions) >= max_solutions:
361
+ return
362
+ if max_unique_starts is not None and len(seen_starts) >= max_unique_starts:
363
+ return
364
+
365
+ slot_end = slot_start + duration_delta
366
+ gap_end = slot_end + gap_delta
367
+
368
+ # Simple datetime comparison -- without Pydantic
369
+ if not self._is_slot_free_fast(slot_start, gap_end, resource_busy): # pragma: no cover
370
+ continue # pragma: no cover
371
+
372
+ # Check maximum chain duration
373
+ if request.max_chain_duration_minutes is not None and chain:
374
+ chain_start = min(c.start_time for c in chain)
375
+ prospective_span = int(
376
+ (max(slot_end, max(c.end_time for c in chain)) - chain_start).total_seconds() / 60
377
+ )
378
+ if prospective_span > request.max_chain_duration_minutes:
379
+ continue # cut off branch - chain is too long
380
+
381
+ chain.append(
382
+ _SlotCandidate(
383
+ service_id=service_req.service_id,
384
+ resource_id=resource_id,
385
+ start_time=slot_start,
386
+ end_time=slot_end,
387
+ gap_end_time=gap_end,
388
+ parallel_group=service_req.parallel_group,
389
+ )
390
+ )
391
+ backtrack(service_index + 1)
392
+ chain.pop()
393
+
394
+ backtrack(0)
395
+ return solutions
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Fast internal checks (without Pydantic -- stdlib only)
399
+ # ---------------------------------------------------------------------------
400
+
401
+ @staticmethod
402
+ def _is_slot_free_fast(
403
+ slot_start: datetime,
404
+ slot_end: datetime,
405
+ busy_intervals: list[tuple[datetime, datetime]],
406
+ ) -> bool:
407
+ """Fast slot availability check. Without Pydantic."""
408
+ return all(not (slot_start < b_end and slot_end > b_start) for b_start, b_end in busy_intervals)
409
+
410
+ @staticmethod
411
+ def _no_conflicts_fast(chain: list["_SlotCandidate"]) -> bool:
412
+ """Final check of the chain for resource conflicts. Without Pydantic."""
413
+ by_resource: dict[str, list[_SlotCandidate]] = {}
414
+ for c in chain:
415
+ if c.resource_id not in by_resource:
416
+ by_resource[c.resource_id] = []
417
+ by_resource[c.resource_id].append(c)
418
+
419
+ for slots in by_resource.values():
420
+ if len(slots) < 2:
421
+ continue
422
+ sorted_slots = sorted(slots, key=lambda s: s.start_time)
423
+ for i in range(len(sorted_slots) - 1):
424
+ if sorted_slots[i + 1].start_time < sorted_slots[i].gap_end_time: # pragma: no cover
425
+ return False # pragma: no cover
426
+
427
+ return True
428
+
429
+ def _effective_min_start(
430
+ self,
431
+ resource_busy: list[tuple[datetime, datetime]],
432
+ buffer_minutes: int,
433
+ ) -> datetime | None:
434
+ """
435
+ Minimum allowable start time for the resource.
436
+
437
+ Considers:
438
+ - self.min_start (global -- "no earlier than N mins from now")
439
+ - End of the last booked slot + buffer between clients
440
+ """
441
+ candidates: list[datetime] = []
442
+
443
+ if self.min_start:
444
+ candidates.append(self.min_start)
445
+
446
+ if resource_busy:
447
+ last_end = max(end for _, end in resource_busy)
448
+ candidates.append(last_end + timedelta(minutes=buffer_minutes))
449
+
450
+ return max(candidates) if candidates else None