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:
co-authored by
Claude Sonnet 5
parent
64e2f86647
commit
b97b95c606
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user