Solveur : retire le plafond de cumul journalier, ajoute un malus de chaînage

- Retiré : le solveur ne pénalise plus un bénévole qui dépasserait 8 h sur
  la journée (`_daily_overage_minutes`/`_DAILY_OVERAGE_PENALTY_PER_MIN`,
  côté back et mock). Seule la validation le signale encore, à la
  confirmation (`DAILY_HOURS_EXCEEDED`, inchangée). `SolverInput.day_parts`
  et son chargement sont retirés au passage : ils ne servaient qu'à ça.
- Ajouté : malus (-40) 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`, calculé par le loader à partir de
  tous les créneaux du poste — pas seulement ceux à compléter). Favorise la
  rotation plutôt que d'enchaîner toujours la même personne sur un poste.
  Reste un malus, pas une exclusion : le seul candidat pour un créneau
  urgent est quand même proposé.
- Miroir côté mock (`runSolver`) pour que la démonstration se comporte pareil.
- CLAUDE.md à jour (§4).

Tests : remplacé le test du plafond journalier par un test de non-régression
explicite (le cumul n'est plus pénalisé) + un test isolant le repos (qui,
lui, reste vérifié) ; ajouté 3 tests pour le chaînage (préfère un bénévole
frais, reste proposé si c'est le seul candidat urgent, un créneau existant
non-prédécesseur ne déclenche pas le malus).

Vérifié : 183 tests backend, ruff, alembic check ; typecheck/lint/build/smoke
frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-15 21:12:30 +02:00
co-authored by Claude Sonnet 5
parent e405590bbc
commit 64e2f86647
6 changed files with 176 additions and 94 deletions
+24 -47
View File
@@ -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()
+19 -14
View File
@@ -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)
+5 -3
View File
@@ -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)