Solveur : le malus de chaînage n'est pas limité au même poste

Correction : le malus ajouté précédemment ne se déclenchait que si le
bénévole avait tenu le créneau précédent du *même* poste. L'intention était
plus générale — limiter les enchaînements de shifts d'un bénévole, quel que
soit le poste, pas seulement faire tourner un poste en particulier.

- `SolverShift.predecessor_shift_id: str | None` → `predecessor_shift_ids:
  frozenset[str]` : l'ensemble des créneaux (n'importe quel poste) du même
  jour dont la fin coïncide avec le début de celui-ci. Peut contenir
  plusieurs ids (plusieurs postes se terminant à la même heure).
- Loader : le calcul du prédécesseur se fait maintenant sur tous les
  créneaux de l'édition regroupés par jour (comparaison des heures de fin),
  plus par poste.
- Miroir côté mock (`buildPredecessorMap`, `runSolver`).
- Nouveau test d'intégration (`test_api_solver.py`) : deux postes différents,
  confirme que le chaînage s'applique bien d'un poste à l'autre.
- CLAUDE.md à jour.

Vérifié : 185 tests backend (dont le nouveau), 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:20:41 +02:00
co-authored by Claude Sonnet 5
parent 64e2f86647
commit b97b95c606
7 changed files with 127 additions and 64 deletions
+13 -9
View File
@@ -7,9 +7,10 @@ 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é, 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.
chaînage entre créneaux consécutifs, 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.
"""
@@ -44,8 +45,9 @@ _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.
# Le bénévole a déjà tenu le créneau immédiatement précédent (même jour,
# n'importe quel poste) : limite les enchaînements de shifts plutôt que de
# faire tourner un poste en particulier.
_CHAIN_PENALTY = 40
@@ -70,11 +72,13 @@ def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bo
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:
"""Le bénévole a déjà (affectation décidée) l'un des créneaux qui
précèdent immédiatement celui-ci, le même jour — n'importe quel poste :
il s'agit de limiter les enchaînements de shifts, pas de faire tourner un
poste en particulier."""
if not shift.predecessor_shift_ids:
return False
return any(s.id == shift.predecessor_shift_id for s in volunteer.existing_shifts)
return any(s.id in shift.predecessor_shift_ids for s in volunteer.existing_shifts)
def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
+18 -15
View File
@@ -40,21 +40,24 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
)
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)
# Prédécesseur(s) : créneaux (n'importe quel poste) du même jour dont la
# fin coïncide avec le début de celui-ci — sert au malus de chaînage
# (voir `engine._score`), pour limiter les enchaînements de shifts plutôt
# que cibler la rotation d'un poste en particulier. Calculé sur tous les
# créneaux de l'édition, pas seulement ceux à compléter.
predecessor_ids: dict = {}
by_day: 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)
by_day[s.starts_at.date()].append(s)
for day_shifts in by_day.values():
ids_by_end: dict = defaultdict(list)
for s in day_shifts:
ids_by_end[s.ends_at].append(str(s.id))
distinct_ends = sorted(ids_by_end)
for s in day_shifts:
earlier_ends = [e for e in distinct_ends if e <= s.starts_at]
if earlier_ends:
predecessor_ids[s.id] = frozenset(ids_by_end[earlier_ends[-1]])
assigned_count: dict = defaultdict(int)
shifts_by_volunteer: dict = defaultdict(list)
@@ -73,7 +76,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),
predecessor_shift_ids=predecessor_ids.get(s.id, frozenset()),
)
for s in shifts
if assigned_count.get(s.id, 0) < s.ideal_staff
+6 -4
View File
@@ -22,9 +22,11 @@ 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
# Créneau(x) — n'importe quel poste — dont la fin coïncide avec le début
# de celui-ci, le même jour (vide s'il n'y en a pas) : sert au malus de
# chaînage (voir `engine._score`), pour limiter les enchaînements de
# shifts plutôt que cibler la rotation d'un poste en particulier.
predecessor_shift_ids: frozenset[str] = frozenset()
@property
def shift_info(self) -> ShiftInfo:
@@ -41,7 +43,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, repos, chaînage sur le même poste.
# comptent pour chevauchement, repos, chaînage.
existing_shifts: tuple[ShiftInfo, ...] = ()
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
existing_minutes: int = 0