Skip to content

Commit

Permalink
Add person wishes
Browse files Browse the repository at this point in the history
  • Loading branch information
gaelrenoux committed Sep 22, 2024
1 parent a36e0b1 commit ff97b2f
Show file tree
Hide file tree
Showing 7 changed files with 180 additions and 9 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ The input file describes the problem to solve. It follows the HOCON format, and
- `mandatory`: An array of topic names for which this person is mandatory: it must be present on those topics. Default is empty.
- `forbidden`: An array of topic names for which this person is forbidden: it cannot be scheduled on those topics. Default is empty.
- `incompatible`: An array of person names this person wish not to share a topic with. Default is empty.
- `wishes`: A mapping of topic names to scores. The higher the score, the more that persons wishes to be on that topic. The scores will be rebalanced so that everyone has a 1000 points total.
- `wishes`: A mapping of topic names to scores. The higher the score, the more that persons wishes to be on that topic. The scores will be rebalanced so that everyone has a 1000 points total (including person wishes, next line).
- `personWishes`: A mapping of person names to scores. The higher the score, the more that persons wishes to be on a topic with the target person. The scores will be rebalanced so that everyone has a 1000 points total (including topic wishes, previous line).
- `constraints`: Global constraints on the schedule.
- `simultaneous`: An array of simultaneity constraints.
- `topics`: An array of topics. Those topics must all be scheduled on the same slot, or not at all.
Expand Down
31 changes: 26 additions & 5 deletions src/main/scala/fr/renoux/gaston/input/InputTranscription.scala
Original file line number Diff line number Diff line change
Expand Up @@ -202,20 +202,40 @@ private[input] final class InputTranscription(rawInput: InputModel) {
PersonGroupAntiPreference(person, group.toBitSet, settings.incompatibilityAntiPreference)
}

/** Person wishes are scaled so that everyone has the same maximum score. This avoids the problem where someone puts
* few preferences or with low value only, where he would always stay "unhappy" and therefore privileged when
* improving the schedule. Right now, we do not handle negative preferences well. */
/** Wishes are scaled so that everyone has the same maximum score. Otherwise, you could put either very small scores
* (and therefore stay the lowest score in the schedule and therefore privileged when improving), or with such
* high values that everyone else's preference don't matter any more.
*
* We do not handle negative preferences here (they're excluded from the total). */
// TODO Right now, negative prefs are ignored in the total count. Either handle them or just forbid negative wishes.
lazy val personScoreFactors: Map[NonEmptyString, Double] = input.personsSet.view.map { inPerson =>
val totalTopicWishesScore = inPerson.wishes.filter(_._2.value > 0).values.sum.value
val totalPersonWishesScore = inPerson.personWishes.filter(_._2.value > 0).values.sum.value
val totalWishScore = totalTopicWishesScore + totalPersonWishesScore
val scoreFactor = Score.PersonTotalScore.value / totalWishScore
inPerson.name -> scoreFactor
}.toMap

lazy val personTopicPreferences: Set[PersonTopicPreference] =
for {
inPerson <- input.personsSet
person = personsByName(inPerson.name)
totalInputScore = inPerson.wishes.filter(_._2.value > 0).values.sum.value // TODO Right now, negative prefs are ignored in the total count
scoreFactor = Score.PersonTotalScore.value / totalInputScore
scoreFactor = personScoreFactors(inPerson.name)
inWish <- inPerson.wishes
wishedTopicName <- NonEmptyString.from(inWish._1).toOption.toSet[NonEmptyString]
topic <- topicsByName(wishedTopicName)
} yield PersonTopicPreference(person, topic, inWish._2 * scoreFactor)

lazy val personPersonPreferences: Set[PersonPersonPreference] =
for {
inPerson <- input.personsSet
person = personsByName(inPerson.name)
scoreFactor = personScoreFactors(inPerson.name)
inWish <- inPerson.personWishes
wishedPersonName <- NonEmptyString.from(inWish._1).toOption.toSet[NonEmptyString]
wishedPerson = personsByName(wishedPersonName)
} yield PersonPersonPreference(person, wishedPerson, inWish._2 * scoreFactor)

def getUnassignedAntiPreference(inPerson: InputPerson): Score = settings.unassigned.personAntiPreferenceScaling match {
case None => settings.unassigned.personAntiPreference
case Some(scalingSettings) =>
Expand Down Expand Up @@ -248,6 +268,7 @@ private[input] final class InputTranscription(rawInput: InputModel) {
linkedParts ++
groupDislikes ++
personTopicPreferences ++
personPersonPreferences ++
unassignedTopicPreferences ++
unassignedTopicsExclusivePreferences
}
Expand Down
3 changes: 2 additions & 1 deletion src/main/scala/fr/renoux/gaston/input/inputModel.scala
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ final case class InputPerson(
mandatory: Set[NonEmptyString] = Set.empty,
forbidden: Set[NonEmptyString] = Set.empty,
incompatible: Set[NonEmptyString] = Set.empty,
wishes: Map[String, Score] = Map.empty // can't use Refined as a key, see https://github.com/fthomas/refined/issues/443
wishes: Map[String, Score] = Map.empty, // can't use Refined as a key, see https://github.com/fthomas/refined/issues/443
personWishes: Map[String, Score] = Map.empty
)

final case class InputGlobalConstraints(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package fr.renoux.gaston.model.preferences

import fr.renoux.gaston.model._

/** Might be an anti-preference if the person really doesn't want to. */
final case class PersonPersonPreference(
person: Person,
targetPerson: Person,
reward: Score
) extends Preference.RecordLevel with Preference.Personal {

override def scoreRecord(record: Record): Score = {
if (record.persons.contains(person) && record.persons.contains(targetPerson)) reward
else Score.Zero
}

override lazy val toLongString: String = s"PersonPersonPreference(${person.toShortString}, ${targetPerson.toShortString}, $reward)"

override lazy val toAbstract: (String, Person.Id, Person.Id, Double) = ("PersonPersonPreference", person.id, targetPerson.id, reward.value)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package fr.renoux.gaston.input
import com.softwaremill.quicklens._
import eu.timepit.refined.auto._
import fr.renoux.gaston.input.InputRefinements.NonPosScore
import fr.renoux.gaston.model.preferences.{PersonTopicPreference, TopicsExclusive}
import fr.renoux.gaston.model.preferences.{PersonPersonPreference, PersonTopicPreference, TopicsExclusive}
import fr.renoux.gaston.model.{Problem, Score}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
Expand Down Expand Up @@ -158,4 +158,96 @@ class InputTranscriptionSpec extends AnyFlatSpec with Matchers {

}



behavior of "Wishes"

{
it should "load topic wishes" in {
val inputSlots = List(List(InputSlot("one"), InputSlot("two")))
val inputTopics = List(InputTopic("alpha"), InputTopic("beta"), InputTopic("gamma"))
val inputPersons = List(InputPerson("Arnold", wishes = Map("alpha" -> Score(50), "gamma" -> Score(100))))
val inputModel = InputModel(
slots = inputSlots,
topics = inputTopics,
persons = inputPersons
)
implicit val problem: Problem = from(inputModel)
val arnold = problem.personsList.find(_.name == "Arnold").get
val alpha = problem.topicsList.find(_.name == "alpha").get
val gamma = problem.topicsList.find(_.name == "gamma").get
val wishes = problem.personalPreferencesListByPerson(arnold)
wishes.size should be(2)
val wishAlpha = wishes.collectFirst { case ptp: PersonTopicPreference if ptp.topic.name == "alpha" => ptp }
wishAlpha.nonEmpty should be(true)
wishAlpha.get.person should be(arnold)
wishAlpha.get.topic should be(alpha)
wishAlpha.get.reward.value should be(333.3 +- 0.1)
val wishGamma = wishes.collectFirst { case ptp: PersonTopicPreference if ptp.topic.name == "gamma" => ptp }
wishGamma.nonEmpty should be(true)
wishGamma.get.person should be(arnold)
wishGamma.get.topic should be(gamma)
wishGamma.get.reward.value should be(666.6 +- 0.1)
}

it should "load person wishes" in {
val inputSlots = List(List(InputSlot("one"), InputSlot("two")))
val inputTopics = List(InputTopic("alpha"), InputTopic("beta"), InputTopic("gamma"))
val inputPersons = List(
InputPerson("Arnold", personWishes = Map("Bianca" -> Score(50), "Charlie" -> Score(100))),
InputPerson("Bianca"), InputPerson("Charlie")
)
val inputModel = InputModel(
slots = inputSlots,
topics = inputTopics,
persons = inputPersons
)
implicit val problem: Problem = from(inputModel)
val arnold = problem.personsList.find(_.name == "Arnold").get
val bianca = problem.personsList.find(_.name == "Bianca").get
val charlie = problem.personsList.find(_.name == "Charlie").get
val wishes = problem.personalPreferencesListByPerson(arnold)
wishes.size should be(2)
val wishBianca = wishes.collectFirst { case ppp: PersonPersonPreference if ppp.targetPerson.name == "Bianca" => ppp }
wishBianca.nonEmpty should be(true)
wishBianca.get.person should be(arnold)
wishBianca.get.targetPerson should be(bianca)
wishBianca.get.reward.value should be(333.3 +- 0.1)
val wishCharlie = wishes.collectFirst { case ppp: PersonPersonPreference if ppp.targetPerson.name == "Charlie" => ppp }
wishCharlie.nonEmpty should be(true)
wishCharlie.get.person should be(arnold)
wishCharlie.get.targetPerson should be(charlie)
wishCharlie.get.reward.value should be(666.6 +- 0.1)
}

it should "take topic and person wishes together" in {
val inputSlots = List(List(InputSlot("one"), InputSlot("two")))
val inputTopics = List(InputTopic("alpha"), InputTopic("beta"), InputTopic("gamma"))
val inputPersons = List(
InputPerson("Arnold", wishes = Map("alpha" -> Score(50)), personWishes = Map("Charlie" -> Score(100))),
InputPerson("Bianca"), InputPerson("Charlie")
)
val inputModel = InputModel(
slots = inputSlots,
topics = inputTopics,
persons = inputPersons
)
implicit val problem: Problem = from(inputModel)
val arnold = problem.personsList.find(_.name == "Arnold").get
val alpha = problem.topicsList.find(_.name == "alpha").get
val charlie = problem.personsList.find(_.name == "Charlie").get
val wishes = problem.personalPreferencesListByPerson(arnold)
wishes.size should be(2)
val wishAlpha = wishes.collectFirst { case ptp: PersonTopicPreference if ptp.topic.name == "alpha" => ptp }
wishAlpha.nonEmpty should be(true)
wishAlpha.get.person should be(arnold)
wishAlpha.get.topic should be(alpha)
wishAlpha.get.reward.value should be(333.3 +- 0.1)
val wishCharlie = wishes.collectFirst { case ppp: PersonPersonPreference if ppp.targetPerson.name == "Charlie" => ppp }
wishCharlie.nonEmpty should be(true)
wishCharlie.get.person should be(arnold)
wishCharlie.get.targetPerson should be(charlie)
wishCharlie.get.reward.value should be(666.6 +- 0.1)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package fr.renoux.gaston.model.preferences

import fr.renoux.gaston.model._
import fr.renoux.gaston.util.Context
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

// scalastyle:off magic.number
class PersonPersonPreferenceSpec extends AnyFlatSpec with Matchers {

import fr.renoux.gaston.MinimalTestModel.Persons._
import fr.renoux.gaston.MinimalTestModel.Problems._
import fr.renoux.gaston.MinimalTestModel.Slots._
import fr.renoux.gaston.MinimalTestModel.Topics._

private implicit val problem: Problem = Minimal
private implicit val context: Context = Context.Default

def scheduled(s: Slot, t: Topic, ps: Person*): Schedule = Schedule.from(s(t(ps: _*)))


behavior of "PersonsPersonPreference"
val leonardoLovesRaphael: Preference = PersonPersonPreference(Leonardo, Raphael, Score(42))

it should "return the score when respected" in {
leonardoLovesRaphael.score(scheduled(Morning, Fighting, Leonardo, Raphael)
) should be(Score(42))
}

it should "return zero when not respected" in {
leonardoLovesRaphael.score(scheduled(Morning, Fighting, Leonardo, Donatello)
++ scheduled(Morning, Machines, Raphael, Michelangelo)
) should be(Score(0))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

// scalastyle:off magic.number
class PersonsTopicPreferenceSpec extends AnyFlatSpec with Matchers {
class PersonTopicPreferenceSpec extends AnyFlatSpec with Matchers {

import fr.renoux.gaston.MinimalTestModel.Persons._
import fr.renoux.gaston.MinimalTestModel.Problems._
Expand Down

0 comments on commit ff97b2f

Please sign in to comment.