Skip to content

Commit

Permalink
Further improve tourney scheduling
Browse files Browse the repository at this point in the history
- Adjust daily overlap to 11.5 hour interval for a conflict,
  fixing a bug where antichess daily (00:00) does not get
  scheduled on the same day as the antichess weekly, even though
  they are 19 hours apart.
  The effect of this can be seen in the snapshot test (9/5 Daily RK)
- Wrote a usurp test which checks a full month of tourneys. This test
  runs in .4s on my laptop thanks to a new, and faster, implementation
  of pruneConflicts.
  Although the faster impl could be used in production, the
  performance difference is not that significant as it's run with
  fewer tourneys. So I've opted to keep the existing, and simpler,
  pruneConflicts method for use in production.
  This is a trade-off between the cost of keeping two methods
  in sync, vs depending on a more complicated and fragile
  pruneConflicts implmentation.
- Delete Eastern Rapid tourney, which was not getting scheduled
  and causes the new usurp test to fail (see lichess-org#16018)
  • Loading branch information
isaacl committed Sep 10, 2024
1 parent e1c6bf5 commit 93d1115
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 20 deletions.
110 changes: 101 additions & 9 deletions modules/tournament/src/main/Schedule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -144,18 +144,35 @@ object Schedule:
at = tour.startsAt.dateTime
)

trait ScheduleWithInterval:
/** Max window for daily or better schedules to be considered overlapping (i.e. if one starts within X hrs
* of the other ending). Using 11.5 hrs here ensures that at least one daily is always cancelled for the
* more important event. But, if a higher importance tourney is scheduled nearly opposite of the daily
* (i.e. 11:00 for a monthly and 00:00 for its daily), two dailies will be cancelled... so don't do this!
*/
private[tournament] val SCHEDULE_DAILY_OVERLAP_MINS = 690 // 11.5 * 60

private[tournament] trait ScheduleWithInterval:
def schedule: Schedule
def interval: TimeInterval
def startsAt: Instant
def duration: java.time.Duration

def endsAt = startsAt.plus(duration)

def interval = TimeInterval(startsAt, duration)

def overlaps(other: ScheduleWithInterval) = interval.overlaps(other.interval)

// Note: this method is must be kept in sync with [[Schedule.pruneConflictsFailOnUsurp]]
// In particular, pruneConflictsFailOnUsurp makes assumptions about which tourneys
// could potentially conflict, by filtering tourneys based on their hours.
def conflictsWith(si2: ScheduleWithInterval) =
val s1 = schedule
val s2 = si2.schedule
s1.variant == s2.variant && (
// prevent daily && weekly on the same day
if s1.freq.isDailyOrBetter && s2.freq.isDailyOrBetter && s1.sameSpeed(s2) then s1.sameDay(s2)
// prevent daily && weekly within X hours of each other
if s1.freq.isDailyOrBetter && s2.freq.isDailyOrBetter && s1.sameSpeed(s2) then
si2.startsAt.minusMinutes(SCHEDULE_DAILY_OVERLAP_MINS).isBefore(endsAt) &&
startsAt.minusMinutes(SCHEDULE_DAILY_OVERLAP_MINS).isBefore(si2.endsAt)
else
(
s1.variant.exotic || // overlapping exotic variant
Expand All @@ -164,7 +181,22 @@ object Schedule:
) && s1.similarConditions(s2) && overlaps(si2)
)

def conflictsWith(scheds: Iterable[ScheduleWithInterval]): Boolean = scheds.exists(conflictsWith)
/** Kept in sync with [[conflictsWithFailOnUsurp]].
*/
def conflictsWith(scheds: Iterable[ScheduleWithInterval]): Boolean =
scheds.exists(conflictsWith)

/** Kept in sync with [[conflictsWith]].
*
* Raises an exception if a tourney is incorrectly usurped.
*/
@throws[IllegalStateException]("if a tourney is incorrectly usurped")
def conflictsWithFailOnUsurp(scheds: Iterable[ScheduleWithInterval]) =
val conflicts = scheds.filter(conflictsWith)
val okConflicts = conflicts.filter(_.schedule.freq >= schedule.freq)
if conflicts.nonEmpty && okConflicts.isEmpty then
throw new IllegalStateException(s"Schedule [$schedule] usurped by ${conflicts}")
conflicts.nonEmpty

case class Plan(schedule: Schedule, buildFunc: Option[Tournament => Tournament])
extends ScheduleWithInterval:
Expand All @@ -177,11 +209,11 @@ object Schedule:
buildFunc = buildFunc.fold(f)(f.compose).some
)

def minutes = durationFor(schedule)
override def startsAt = schedule.atInstant

def duration = java.time.Duration.ofMinutes(minutes)
def minutes = durationFor(schedule)

def interval = TimeInterval(schedule.atInstant, duration)
override def duration = java.time.Duration.ofMinutes(minutes)

enum Freq(val id: Int, val importance: Int) extends Ordered[Freq] derives Eq:
case Hourly extends Freq(10, 10)
Expand Down Expand Up @@ -389,7 +421,9 @@ object Schedule:
)

/** Given a list of existing schedules and a list of possible new plans, returns a subset of the possible
* plans that do not conflict with either the existing schedules or with themselves.
* plans that do not conflict with either the existing schedules or with themselves. Intended to produce
* identical output to [[ForTesting.pruneConflictsForTesting]], but this variant is more readable and has
* lower potential for bugs.
*/
private[tournament] def pruneConflicts(
existingSchedules: Iterable[ScheduleWithInterval],
Expand All @@ -403,3 +437,61 @@ object Schedule:
allPlannedSchedules = p :: allPlannedSchedules
p :: newPlans
.reverse

private[tournament] object ForTesting:
import java.time.temporal.ChronoUnit
import scala.collection.mutable.{ ArrayBuffer, LongMap }

private val MS_PER_HR = 3600L * 1000
// Round up to nearest whole hour and convert to ms
private val DAILY_OVERLAP_MS = Math.ceil(SCHEDULE_DAILY_OVERLAP_MINS / 60.0).toLong * MS_PER_HR

private def firstHourMs(s: ScheduleWithInterval) =
s.startsAt.truncatedTo(ChronoUnit.HOURS).toEpochMilli

/** Returns a Seq of each hour (as epoch ms) that a tournament overlaps with, for use in a hash map.
*/
def getAllHours(s: ScheduleWithInterval) =
(firstHourMs(s) until s.endsAt.toEpochMilli by MS_PER_HR)

/** Returns a Seq of possible hours (as epoch ms) that another tournament could exist that would be
* considered a conflict. This results in pulling all tournaments within a sliding window. The window is
* smaller when the tournament is an hourly, as these only conflict with tournaments that actually
* overlap. Daily or better tournaments can conflict with another daily or better in a larger window as
* well as with hourlies.
*/
def getConflictingHours(s: ScheduleWithInterval) =
// Tourneys of daily or better can conflict with another tourney within a sliding window
if s.schedule.freq.isDailyOrBetter then
(firstHourMs(s) - DAILY_OVERLAP_MS until
s.endsAt.toEpochMilli + DAILY_OVERLAP_MS by
MS_PER_HR)
else getAllHours(s)

/** Given a list of existing schedules and a list of possible new plans, returns a subset of the possible
* plans that do not conflict with either the existing schedules or with themselves.
*
* Returns the same result as [[Schedule.pruneConflicts]], but is asymptotically more efficient,
* performing O(n) operations of [[ScheduleWithInterval.conflictsWith(WithScheduleWithInterval):*]]
* rather than O(n^2), n being the number of inputs.
*/
@throws[IllegalStateException]("if a tourney is incorrectly usurped")
def pruneConflictsFailOnUsurp(
existingSchedules: Iterable[ScheduleWithInterval],
possibleNewPlans: Iterable[Plan]
): List[Plan] =
// Bucket schedules by hour for faster conflict detection
val hourMap = LongMap.empty[ArrayBuffer[ScheduleWithInterval]]
def addToMap(hour: Long, s: ScheduleWithInterval) =
hourMap.getOrElseUpdate(hour, ArrayBuffer.empty).addOne(s)

existingSchedules.foreach { s => getAllHours(s).foreach { addToMap(_, s) } }

possibleNewPlans
.foldLeft(List[Plan]()): (newPlans, p) =>
val potentialConflicts = getConflictingHours(p).flatMap { hourMap.get(_) }
if p.conflictsWithFailOnUsurp(potentialConflicts.flatten) then newPlans
else
getAllHours(p).foreach { addToMap(_, p) }
p :: newPlans
.reverse
12 changes: 7 additions & 5 deletions modules/tournament/src/main/TournamentScheduler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import lila.gathering.Condition
*/
private[tournament] case class ConcreteSchedule(
schedule: Schedule,
interval: TimeInterval
startsAt: Instant,
duration: java.time.Duration
) extends Schedule.ScheduleWithInterval

final private class TournamentScheduler(tournamentRepo: TournamentRepo)(using
Expand All @@ -38,7 +39,7 @@ final private class TournamentScheduler(tournamentRepo: TournamentRepo)(using

val existingSchedules = dbScheds.flatMap { t =>
// Ignore tournaments with schedule=None - they never conflict.
t.schedule.map { ConcreteSchedule(_, t.interval) }
t.schedule.map { ConcreteSchedule(_, t.startsAt, t.duration) }
}

val prunedPlans = Schedule.pruneConflicts(existingSchedules, plans)
Expand Down Expand Up @@ -268,6 +269,8 @@ Thank you all, you rock!""".some,
Schedule(Weekend, speed, Standard, none, date.pipe(orNextWeek)).plan
}
},
// Note: these should be scheduled close to the hour of weekly or weekend tournaments
// to avoid two dailies being cancelled in a row from a single higher importance tourney
List( // daily tournaments!
at(today, 16).map { date =>
Schedule(Daily, Bullet, Standard, none, date.pipe(orTomorrow)).plan
Expand All @@ -288,6 +291,8 @@ Thank you all, you rock!""".some,
Schedule(Daily, UltraBullet, Standard, none, date.pipe(orTomorrow)).plan
}
).flatten,
// Note: these should be scheduled close to the hour of weekly variant tournaments
// to avoid two dailies being cancelled in a row from a single higher importance tourney
List( // daily variant tournaments!
at(today, 20).map { date =>
Schedule(Daily, Blitz, Crazyhouse, none, date.pipe(orTomorrow)).plan
Expand Down Expand Up @@ -323,9 +328,6 @@ Thank you all, you rock!""".some,
},
at(today, 6).map { date =>
Schedule(Eastern, Blitz, Standard, none, date.pipe(orTomorrow)).plan
},
at(today, 7).map { date =>
Schedule(Eastern, Rapid, Standard, none, date.pipe(orTomorrow)).plan
}
).flatten, {
{
Expand Down
32 changes: 26 additions & 6 deletions modules/tournament/src/test/SchedulerTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,12 @@ class SchedulerTest extends munit.FunSuite:
// Prune conflicts in a similar manner to how it is done in production i.e. TournamentScheduler:
// schedule earlier hours first, and then add later hour schedules if they don't conflict.
// In production, existing tournaments come from db, but the effect is the same.
Schedule
.pruneConflicts(
Schedule.ForTesting
.pruneConflictsFailOnUsurp(
List.empty,
(-24 to 23).flatMap { hour =>
val start = startOfDay.plusHours(hour)
(-24 to 23).flatMap { hours =>
TournamentScheduler
.allWithConflicts(start)
.allWithConflicts(startOfDay.plusHours(hours))
.filter(_.interval.overlaps(dayInterval))
}
)
Expand All @@ -43,6 +42,27 @@ class SchedulerTest extends munit.FunSuite:
def _printSnapshot(plans: List[?]) =
println(plans.mkString(" List(\"\"\"", "\"\"\",\n \"\"\"", "\"\"\").mkString(\"\\n\")"))

test("2024-09 - No usurps"):
val start = instantOf(2024, 9, 1, 0, 0)
val days = 31
Schedule.ForTesting.pruneConflictsFailOnUsurp(
List.empty,
// Hour by hour schedules for the entire month.
(0 to (days * 24)).flatMap { hours =>
TournamentScheduler.allWithConflicts(start.plusHours(hours))
}
)

test("pruneConflict methods produce identical results"):
val start = instantOf(2024, 8, 1, 0, 0)
val allTourneys = (0 to 23).flatMap { hours =>
TournamentScheduler.allWithConflicts(start.plusHours(hours))
}
assertEquals(
Schedule.ForTesting.pruneConflictsFailOnUsurp(List.empty, allTourneys),
Schedule.pruneConflicts(List.empty, allTourneys)
)

test("2024-09-05 - thursday, summer"):
// uncomment to print text for updating snapshot.
// _printSnapshot(fullDaySchedule(instantOf(2024, 9, 5, 0, 0)))
Expand Down Expand Up @@ -390,6 +410,7 @@ class SchedulerTest extends munit.FunSuite:
"""2024-09-05T19:00:00Z Hourly horde blitz(5+0) Conditions() None (09-05T15:00 EDT, 09-05T21:00 CEST) 57m""",
"""2024-09-05T22:00:00Z Hourly horde blitz(5+0) Conditions() None (09-05T18:00 EDT, 09-06T00:00 CEST) 57m""",
"""2024-09-05T02:00:00Z Hourly racingKings hippoBullet(2+0) Conditions() None (09-04T22:00 EDT, 09-05T04:00 CEST) 57m""",
"""2024-09-05T03:00:00Z Daily racingKings superBlitz(3+0) Conditions() None (09-04T23:00 EDT, 09-05T05:00 CEST) 60m""",
"""2024-09-05T05:00:00Z Hourly racingKings blitz(5+0) Conditions() None (09-05T01:00 EDT, 09-05T07:00 CEST) 57m""",
"""2024-09-05T08:00:00Z Hourly racingKings blitz(5+0) Conditions() None (09-05T04:00 EDT, 09-05T10:00 CEST) 57m""",
"""2024-09-05T11:00:00Z Hourly racingKings superBlitz(3+0) Conditions() None (09-05T07:00 EDT, 09-05T13:00 CEST) 57m""",
Expand Down Expand Up @@ -869,7 +890,6 @@ class SchedulerTest extends munit.FunSuite:
"""2023-01-01T00:00:00Z Hourly standard rapid(10+0) Conditions() None""",
"""2023-01-01T02:00:00Z Hourly standard rapid(10+0) Conditions() None""",
"""2023-01-01T06:00:00Z Hourly standard rapid(10+0) Conditions() Some(rnbqkbnr/ppp1pppp/8/8/2pP4/8/PP2PPPP/RNBQKBNR w KQkq -)""",
"""2023-01-01T07:00:00Z Eastern standard rapid(10+0) Conditions() None""",
"""2023-01-01T14:00:00Z Hourly standard rapid(10+0) Conditions() Some(rnbqkbnr/pppp1p1p/8/6p1/4Pp2/5N2/PPPP2PP/RNBQKB1R w KQkq -)""",
"""2023-01-01T19:00:00Z Daily standard rapid(10+0) Conditions() None""",
"""2023-01-05T16:00:00Z Shield standard rapid(10+0) Conditions() None""",
Expand Down

0 comments on commit 93d1115

Please sign in to comment.