diff --git a/CLAUDE.md b/CLAUDE.md index 6c0312c..e303fe3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,18 +274,25 @@ affectation n'est jamais persistée par cet endpoint. - bonus si le poste fait partie des vœux du bénévole (`VolunteerPositionWish`) ; - malus proportionnel aux minutes hors des fenêtres de disponibilité - déclarées (réutilise `validation.engine.uncovered_minutes`), au dépassement - du cumul de 8 h ce jour-là, et si le repos avec une affectation déjà - décidée serait `< 8 h 00` ; + déclarées (réutilise `validation.engine.uncovered_minutes`), et si le + repos avec une affectation déjà décidée serait `< 8 h 00` ; + - malus si le bénévole a déjà (affectation décidée) le créneau précédent de + ce même poste, le même jour (`SolverShift.predecessor_shift_id`) — + favorise la rotation plutôt que d'enchaîner toujours la même personne sur + un poste ; - malus si le créneau chevauche le passage d'un artiste que le bénévole veut voir (`VolunteerArtist` + `Artist.start_time`/`end_time`) — lui ferait manquer le passage ; - léger malus pour les bénévoles déjà bien chargés (répartition de la charge). - - Simplification assumée : cumul de 8 h et repos ne sont vérifiés que - contre les affectations **déjà décidées**, pas entre deux suggestions du - même calcul — la validation finale à la confirmation reste seule juge de - ce cas limite. + - **Pas de plafond de cumul journalier côté solveur** : contrairement à la + validation (`DAILY_HOURS_EXCEEDED`, § 3), le solveur ne pénalise pas un + bénévole qui dépasserait 8 h sur la journée — seule la validation le + signale, à la confirmation. + - Simplification assumée : repos et chaînage ne sont vérifiés que contre + les affectations **déjà décidées**, pas entre deux suggestions du même + calcul — la validation finale à la confirmation reste seule juge de ce + cas limite. **Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts` (`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de diff --git a/backend/app/solver/engine.py b/backend/app/solver/engine.py index b4536ff..43e2782 100644 --- a/backend/app/solver/engine.py +++ b/backend/app/solver/engine.py @@ -6,10 +6,12 @@ coordination qui affecte (dans l'app, via l'écran habituel — le solveur se contente de faire remonter ces candidats en tête de liste). Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle -`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, cumul de -8 h, repos, vœu de poste, vœu d'artiste, équité de charge) est un terme de -l'objectif, pas une contrainte — comme le moteur de validation, qui reste seul -juge au moment où la coordination confirme une affectation. +`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos, +chaînage sur le même poste, vœu de poste, vœu d'artiste, équité de charge) est +un terme de l'objectif, pas une contrainte — comme le moteur de validation, +qui reste seul juge au moment où la coordination confirme une affectation. +Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED` +reste vérifiée par la validation, à la confirmation. """ import datetime as dt @@ -24,7 +26,7 @@ from app.solver.models import ( SolverVolunteer, ) from app.validation.engine import uncovered_minutes -from app.validation.models import DayPartWindow, ShiftInfo +from app.validation.models import ShiftInfo _EIGHT_HOURS_MIN = 8 * 60 @@ -38,45 +40,19 @@ _URGENT_BASE = 1000 # créneau encore sous son effectif minimal _NORMAL_BASE = 20 # créneau entre effectif minimal et idéal _WISH_BONUS = 30 _AVAIL_PENALTY_PER_HALF_HOUR = 5 -_DAILY_OVERAGE_PENALTY_PER_MIN = 2 _REST_PENALTY = 60 _FAIRNESS_PENALTY_PER_HOUR = 2 # Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir. _ARTIST_CONFLICT_PENALTY = 80 +# Le bénévole a déjà tenu le créneau précédent de ce même poste (même jour) : +# favorise la rotation plutôt que d'enchaîner toujours la même personne. +_CHAIN_PENALTY = 40 def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool: return a.starts_at < b.ends_at and b.starts_at < a.ends_at -def _day_boundary(day_parts: tuple[DayPartWindow, ...]) -> dt.time: - if not day_parts: - return dt.time(0, 0) - return min(day_parts, key=lambda part: part.order).start_time - - -def _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date: - if moment.time() >= boundary: - return moment.date() - return moment.date() - dt.timedelta(days=1) - - -def _daily_overage_minutes( - volunteer: SolverVolunteer, shift: SolverShift, boundary: dt.time -) -> int: - """Minutes qui dépasseraient les 8 h de la journée du créneau, en ne - comptant que les affectations déjà décidées (pas les autres suggestions - du même calcul — ce cas limite reste sous la vigilance du moteur de - validation au moment de la confirmation).""" - key = _day_key(shift.starts_at, boundary) - total = shift.ends_at - shift.starts_at - for other in volunteer.existing_shifts: - if _day_key(other.starts_at, boundary) == key: - total += other.ends_at - other.starts_at - minutes = int(total.total_seconds() // 60) - return max(0, minutes - _EIGHT_HOURS_MIN) - - def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool: candidate = shift.shift_info for other in volunteer.existing_shifts: @@ -93,16 +69,20 @@ def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bo return False -def _score( - volunteer: SolverVolunteer, - shift: SolverShift, - day_boundary: dt.time, -) -> int: +def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool: + """Le bénévole a déjà (affectation décidée) le créneau précédent de ce + même poste, le même jour.""" + if shift.predecessor_shift_id is None: + return False + return any(s.id == shift.predecessor_shift_id for s in volunteer.existing_shifts) + + +def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int: """Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat. Purement additif (aucune interaction entre deux affectations proposées), - ce qui garde le modèle CP-SAT linéaire simple. Cumul de 8 h et repos ne - sont vérifiés que contre les affectations déjà décidées, pas entre deux + ce qui garde le modèle CP-SAT linéaire simple. Repos et chaînage ne sont + vérifiés que contre les affectations déjà décidées, pas entre deux suggestions du même calcul : la validation finale (à la confirmation) reste seule juge en cas de cas limite entre deux suggestions. """ @@ -111,11 +91,10 @@ def _score( score += _WISH_BONUS missed = uncovered_minutes(shift.shift_info, volunteer.availability) score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR - score -= _DAILY_OVERAGE_PENALTY_PER_MIN * _daily_overage_minutes( - volunteer, shift, day_boundary - ) if _has_insufficient_rest(volunteer, shift): score -= _REST_PENALTY + if _chained_from_predecessor(volunteer, shift): + score -= _CHAIN_PENALTY if any( _overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows ): @@ -131,8 +110,6 @@ def solve( seed: int = 42, ) -> SolverSolution: """Calcule les suggestions. Fonction pure : ne touche jamais la base.""" - boundary = _day_boundary(data.day_parts) - model = cp_model.CpModel() variables: dict[tuple[int, int], cp_model.IntVar] = {} scores: dict[tuple[int, int], int] = {} @@ -146,7 +123,7 @@ def solve( continue key = (vi, si) variables[key] = model.NewBoolVar(f"x_{vi}_{si}") - scores[key] = _score(volunteer, shift, boundary) + scores[key] = _score(volunteer, shift) if not variables: return SolverSolution() diff --git a/backend/app/solver/loader.py b/backend/app/solver/loader.py index 499bb05..cbe1451 100644 --- a/backend/app/solver/loader.py +++ b/backend/app/solver/loader.py @@ -10,7 +10,6 @@ from app.models import ( Artist, Assignment, Availability, - DayPart, Edition, Shift, Volunteer, @@ -18,7 +17,7 @@ from app.models import ( VolunteerPositionWish, ) from app.solver.models import SolverInput, SolverShift, SolverVolunteer -from app.validation.models import AvailabilityWindow, DayPartWindow, ShiftInfo +from app.validation.models import AvailabilityWindow, ShiftInfo def _artist_window(artist: Artist) -> ShiftInfo: @@ -35,16 +34,28 @@ def _artist_window(artist: Artist) -> ShiftInfo: def build_solver_input(*, session: Session, edition: Edition) -> SolverInput: - day_parts = list( - session.exec(select(DayPart).where(DayPart.edition_id == edition.id)) - ) - shifts = list(session.exec(select(Shift).where(Shift.edition_id == edition.id))) assignments = list( session.exec(select(Assignment).where(Assignment.edition_id == edition.id)) ) shifts_by_id = {s.id: s for s in shifts} + # Prédécesseur (même poste, même jour, créneau juste avant) : sert au + # malus de chaînage (voir `engine._score`). Calculé sur tous les créneaux + # du poste, pas seulement ceux à compléter. + predecessor_id: dict = {} + by_position: dict = defaultdict(list) + for s in shifts: + by_position[s.position_id].append(s) + for position_shifts in by_position.values(): + by_day: dict = defaultdict(list) + for s in position_shifts: + by_day[s.starts_at.date()].append(s) + for day_shifts in by_day.values(): + day_shifts.sort(key=lambda s: s.starts_at) + for previous, current in zip(day_shifts, day_shifts[1:], strict=False): + predecessor_id[current.id] = str(previous.id) + assigned_count: dict = defaultdict(int) shifts_by_volunteer: dict = defaultdict(list) for a in assignments: @@ -62,6 +73,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput: min_staff=s.min_staff, ideal_staff=s.ideal_staff, already_assigned=assigned_count.get(s.id, 0), + predecessor_shift_id=predecessor_id.get(s.id), ) for s in shifts if assigned_count.get(s.id, 0) < s.ideal_staff @@ -118,11 +130,4 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput: for v in volunteers ) - return SolverInput( - shifts=solver_shifts, - volunteers=solver_volunteers, - day_parts=tuple( - DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) - for dp in day_parts - ), - ) + return SolverInput(shifts=solver_shifts, volunteers=solver_volunteers) diff --git a/backend/app/solver/models.py b/backend/app/solver/models.py index 5777116..17a40e5 100644 --- a/backend/app/solver/models.py +++ b/backend/app/solver/models.py @@ -8,7 +8,7 @@ SQLModel), comme le moteur de validation dont il réutilise les briques import datetime as dt from dataclasses import dataclass, field -from app.validation.models import AvailabilityWindow, DayPartWindow, ShiftInfo +from app.validation.models import AvailabilityWindow, ShiftInfo @dataclass(frozen=True) @@ -22,6 +22,9 @@ class SolverShift: min_staff: int ideal_staff: int already_assigned: int + # Créneau du même poste, même jour, juste avant celui-ci (`None` s'il n'y + # en a pas) — sert au malus de chaînage (voir `engine._score`). + predecessor_shift_id: str | None = None @property def shift_info(self) -> ShiftInfo: @@ -38,7 +41,7 @@ class SolverVolunteer: # lui ferait manquer le passage. wished_artist_windows: tuple[ShiftInfo, ...] = () # Créneaux déjà occupés (affectations existantes, décisions figées) : - # comptent pour chevauchement, cumul journalier, repos. + # comptent pour chevauchement, repos, chaînage sur le même poste. existing_shifts: tuple[ShiftInfo, ...] = () # Total déjà affecté sur l'édition (minutes), pour répartir la charge. existing_minutes: int = 0 @@ -48,7 +51,6 @@ class SolverVolunteer: class SolverInput: shifts: tuple[SolverShift, ...] = field(default_factory=tuple) volunteers: tuple[SolverVolunteer, ...] = field(default_factory=tuple) - day_parts: tuple[DayPartWindow, ...] = field(default_factory=tuple) @dataclass(frozen=True) diff --git a/backend/tests/test_solver_engine.py b/backend/tests/test_solver_engine.py index cc2a14b..e4ae125 100644 --- a/backend/tests/test_solver_engine.py +++ b/backend/tests/test_solver_engine.py @@ -20,6 +20,7 @@ def _shift( min_staff=1, ideal_staff=1, already_assigned=0, + predecessor_shift_id: str | None = None, ) -> SolverShift: return SolverShift( id=sid, @@ -29,6 +30,7 @@ def _shift( min_staff=min_staff, ideal_staff=ideal_staff, already_assigned=already_assigned, + predecessor_shift_id=predecessor_shift_id, ) @@ -138,18 +140,77 @@ def test_never_double_books_a_volunteer_across_two_new_suggestions(): assert len(picked_a) + len(picked_b) == 1 -def test_avoids_volunteer_who_would_exceed_eight_hours_that_day(): - shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) # 4 h +def test_avoids_volunteer_with_insufficient_rest_since_last_shift(): + # < 8 h entre la fin d'un créneau déjà décidé et le début de celui-ci. + shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) tired = _volunteer( "tired", availability=[_full_day(10)], - existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 12))], # 6 h déjà + existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))], # repos 4 h ) fresh = _volunteer("fresh", availability=[_full_day(10)]) solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh))) assert _by_shift(solution, "s1") == ("fresh",) +def test_daily_cumulative_hours_no_longer_penalized(): + # Le solveur ne plafonne plus le cumul de 8 h/jour (retiré) : seule la + # validation, à la confirmation, le signale désormais + # (`DAILY_HOURS_EXCEEDED`). Repos respecté (8 h pile) ; cumul du jour + # (7 h + 2 h = 9 h) aurait auparavant été pénalisé. + shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23)) + long_day = _volunteer( + "long_day", + availability=[_full_day(10)], + existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))], + ) + fresh = _volunteer("fresh", availability=[_full_day(10)]) + solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh))) + assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"} + + +def test_prefers_volunteer_without_the_previous_shift_on_the_same_position(): + # Même poste, repos suffisant (8 h pile) entre les deux créneaux : seul + # le chaînage doit différencier les deux bénévoles. + shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_id="prev") + chained = _volunteer( + "chained", + availability=[_full_day(10)], + existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))], + ) + fresh = _volunteer("fresh", availability=[_full_day(10)]) + solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh))) + assert _by_shift(solution, "s1") == ("fresh",) + + +def test_still_suggests_the_only_candidate_despite_chaining(): + # Le chaînage reste un avertissement, pas un blocage : personne d'autre + # pour ce créneau urgent, le solveur le propose quand même. + shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_id="prev") + chained = _volunteer( + "chained", + availability=[_full_day(10)], + existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))], + ) + solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,))) + assert _by_shift(solution, "s1") == ("chained",) + + +def test_an_unrelated_existing_shift_does_not_trigger_the_chain_penalty(): + # Le bénévole a bien un créneau existant, mais ce n'est pas le + # prédécesseur désigné de ce créneau (ex. un autre poste) : pas de malus + # de chaînage (le prédécesseur est identifié par id, pas par proximité). + # Les deux candidats doivent donc être à égalité. + shift = _shift(ideal_staff=2, start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_id="prev") + unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12)) + almost_chained = _volunteer( + "almost_chained", availability=[_full_day(10)], existing_shifts=[unrelated] + ) + truly_fresh = _volunteer("truly_fresh", availability=[_full_day(10)]) + solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh))) + assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"} + + def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift(): # Le créneau (14 h - 18 h) chevauche le passage de l'artiste (16 h - 17 h) # que « fan » veut voir : le solveur préfère « indiff », qui n'a pas ce diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 5af27ba..eef9b98 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -389,10 +389,12 @@ const SOLVER_URGENT_BASE = 1000; const SOLVER_NORMAL_BASE = 20; const SOLVER_WISH_BONUS = 30; const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5; -const SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN = 2; const SOLVER_REST_PENALTY = 60; const SOLVER_ARTIST_CONFLICT_PENALTY = 80; const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2; +// Le bénévole a déjà tenu le créneau précédent de ce même poste (même jour) : +// favorise la rotation plutôt que d'enchaîner toujours la même personne. +const SOLVER_CHAIN_PENALTY = 40; type TimeWindow = { starts_at: string; ends_at: string }; @@ -406,6 +408,34 @@ function volunteerExistingShifts(volunteerId: string): ShiftRead[] { .map((a) => shiftById(a.shift_id)); } +/** Créneau de même poste, même jour, juste avant chaque créneau (indexé par + * id de créneau) — sert au malus de chaînage (voir `solverScore`). */ +function buildPredecessorMap(): Map { + const byPosition = new Map(); + for (const s of state.shifts) { + const list = byPosition.get(s.position_id) ?? []; + list.push(s); + byPosition.set(s.position_id, list); + } + const predecessor = new Map(); + for (const positionShifts of byPosition.values()) { + const byDay = new Map(); + for (const s of positionShifts) { + const day = s.starts_at.slice(0, 10); + const list = byDay.get(day) ?? []; + list.push(s); + byDay.set(day, list); + } + for (const dayShifts of byDay.values()) { + dayShifts.sort((a, b) => a.starts_at.localeCompare(b.starts_at)); + for (let i = 1; i < dayShifts.length; i += 1) { + predecessor.set(dayShifts[i].id, dayShifts[i - 1].id); + } + } + } + return predecessor; +} + /** Fenêtres datées des passages que le bénévole veut voir (un créneau qui * chevauche l'une d'elles lui ferait manquer le passage). */ function wishedArtistWindows(volunteer: VolunteerRead): TimeWindow[] { @@ -424,26 +454,19 @@ function solverScore( assignedCount: number, existing: ShiftRead[], wishedArtists: TimeWindow[], + chainedFromPredecessor: boolean, ): number { let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE; if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS; if (wishedArtists.some((w) => timeRangesOverlap(shift, w))) { score -= SOLVER_ARTIST_CONFLICT_PENALTY; } + if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY; const windows = state.availability.get(volunteer.id) ?? []; const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at); score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR; - const dayKey = shift.starts_at.slice(0, 10); - let totalMin = naiveMinutes(shift.ends_at) - naiveMinutes(shift.starts_at); - for (const other of existing) { - if (other.starts_at.slice(0, 10) === dayKey) { - totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at); - } - } - score -= Math.max(0, totalMin - EIGHT_HOURS_MIN) * SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN; - for (const other of existing) { if (overlaps(shift, other)) continue; let gap: number | null = null; @@ -482,6 +505,7 @@ function runSolver(): SolverRunResult { existingByVolunteer.set(v.id, volunteerExistingShifts(v.id)); artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v)); } + const predecessorByShift = buildPredecessorMap(); // Suggestions déjà retenues dans ce calcul, pour ne jamais proposer deux // fois un même bénévole sur des créneaux qui se chevauchent. const takenByVolunteer = new Map(); @@ -503,16 +527,22 @@ function runSolver(): SolverRunResult { ]; return !busy.some((other) => overlaps(shift, other)); }) - .map((v) => ({ - volunteer: v, - score: solverScore( - v, - shift, - assignedCount.get(shift.id) ?? 0, - existingByVolunteer.get(v.id) ?? [], - artistWindowsByVolunteer.get(v.id) ?? [], - ), - })) + .map((v) => { + const existing = existingByVolunteer.get(v.id) ?? []; + const predecessorId = predecessorByShift.get(shift.id); + const chained = predecessorId != null && existing.some((s) => s.id === predecessorId); + return { + volunteer: v, + score: solverScore( + v, + shift, + assignedCount.get(shift.id) ?? 0, + existing, + artistWindowsByVolunteer.get(v.id) ?? [], + chained, + ), + }; + }) .filter((c) => c.score > 0) .sort((a, b) => b.score - a.score) .slice(0, gap);