Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
06ef1b0
add some TODOs regarding determinism; support random number generator…
RandomHashTags Mar 18, 2026
2a27e39
adopt `OrderedSet` from `swift-collections` to enable deterministic o…
RandomHashTags Mar 19, 2026
cf5d467
fully support custom determinism values
RandomHashTags Mar 19, 2026
a638f90
allow `UInt64.max` to be chosen as the seed
RandomHashTags Mar 19, 2026
846f851
avoid crash calculating `availableMatchupPairs`; unit test updates
RandomHashTags Mar 19, 2026
5ec7932
allow custom `multiplier` and `increment` determinism values and...
RandomHashTags Mar 19, 2026
ebb2f7f
support both determinism pathways (deterministic & non-deterministic)
RandomHashTags Mar 20, 2026
1d0c3bb
minor improvements
RandomHashTags Mar 20, 2026
59ba9fa
use non-deterministic output by default for the unit tests
RandomHashTags Mar 20, 2026
ebaf9b0
drop the `Deterministic` name prefix for the `ScheduleConfiguration` …
RandomHashTags Mar 20, 2026
3765350
remove unused `OrderedCollections` imports
RandomHashTags Mar 20, 2026
1caa455
stuff
RandomHashTags Mar 20, 2026
9b60941
minor code cleanups
RandomHashTags Mar 20, 2026
ccaceb0
support more determinism; remove some memberless protocols
RandomHashTags Mar 20, 2026
2998fac
support more determinism for `RedistributionData`
RandomHashTags Mar 20, 2026
bd3a106
remove unused `AbstractArray`
RandomHashTags Mar 20, 2026
98e23e5
use `subscript(unchecked:)` when calling `availableMatchupPairs`
RandomHashTags Mar 20, 2026
777c384
documentation fix
RandomHashTags Mar 20, 2026
195ef6b
remove two unused imports
RandomHashTags Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/RandomHashTags/swift-staticdatetime", from: "0.3.5"),

// Ordered sets
.package(url: "https://github.com/apple/swift-collections", from: "1.4.0"),

// Protocol buffers
.package(url: "https://github.com/apple/swift-protobuf", from: "1.31.0"),
],
targets: [
.target(
name: "LeagueScheduling",
dependencies: [
.product(name: "OrderedCollections", package: "swift-collections"),
.product(name: "StaticDateTimes", package: "swift-staticdatetime"),
.product(name: "SwiftProtobuf", package: "swift-protobuf")
],
Expand Down
39 changes: 39 additions & 0 deletions Sources/ProtocolBuffers/Determinism.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright 2026 Evan Anderson. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Evan Anderson nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

syntax = "proto3";

package lit_leagues.leagues;

// Constraints that influence how deterministic the schedule generation process is.
message Determinism {
optional uint32 technique = 1;
optional uint64 seed = 2;
optional uint64 multiplier = 3;
optional uint64 increment = 4;
}
5 changes: 5 additions & 0 deletions Sources/ProtocolBuffers/GenerationConstraints.proto
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ syntax = "proto3";

package lit_leagues.leagues;

import "Determinism.proto";

// Constraints that influence the schedule generation process.
message GenerationConstraints {
// Maximum number of seconds the schedule can take to generate. Default value is 60; 0=infinite (will continue until the regeneration attempt threshold is met).
Expand All @@ -44,4 +46,7 @@ message GenerationConstraints {
// Maximum number of total regeneration attempts before stopping execution and marking the schedule generation as a failure.
// Default value is 10,000.
optional uint32 regenerationAttemptsThreshold = 4;

// Deterministic constraints. If not provided, the output is non-deterministic (heavily relies on randomness and probabilities).
optional Determinism determinism = 5;
}
12 changes: 6 additions & 6 deletions Sources/league-scheduling/data/AssignMatchup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ extension LeagueScheduleData {
@discardableResult
mutating func assignMatchupPair(
_ pair: MatchupPair,
allAvailableMatchups: Set<MatchupPair>,
allAvailableMatchups: Config.MatchupPairSet,
selectSlot: borrowing some SelectSlotProtocol & ~Copyable,
canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable
) -> Matchup? {
Expand Down Expand Up @@ -34,7 +34,7 @@ extension AssignmentState {
gameGap: GameGap.TupleValue,
entryMatchupsPerGameDay: EntryMatchupsPerGameDay,
divisionRecurringDayLimitInterval: ContiguousArray<RecurringDayLimitInterval>,
allAvailableMatchups: Set<MatchupPair>,
allAvailableMatchups: Config.MatchupPairSet,
selectSlot: borrowing some SelectSlotProtocol & ~Copyable,
canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable
) -> Matchup? {
Expand Down Expand Up @@ -84,20 +84,20 @@ extension AssignmentState {
#if LOG
print("assignMatchupPair;pair=\(pair.description);slot==nil, removing pair from availableMatchups")
#endif
availableMatchups.remove(pair)
availableMatchups.removeMember(pair)
return nil
}
}

// MARK: Playable slots
extension AssignmentState {
func playableSlots(for pair: MatchupPair) -> Set<AvailableSlot> {
func playableSlots(for pair: MatchupPair) -> Config.AvailableSlotSet {
return Self.playableSlots(for: pair, remainingAllocations: remainingAllocations)
}
static func playableSlots(
for pair: MatchupPair,
remainingAllocations: RemainingAllocations
) -> Set<AvailableSlot> {
remainingAllocations: Config.RemainingAllocations
) -> Config.AvailableSlotSet {
return remainingAllocations[unchecked: pair.team1].intersection(remainingAllocations[unchecked: pair.team2])
}
}
58 changes: 30 additions & 28 deletions Sources/league-scheduling/data/AssignSlots.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ extension LeagueScheduleData {
var assignmentIndex = 0
var fms = failedMatchupSelections[unchecked: assignmentIndex]
var optimalAvailableMatchups = assignmentState.availableMatchups.filter { !fms.contains($0) }
var prioritizedMatchups = PrioritizedMatchups(
var prioritizedMatchups = PrioritizedMatchups<Config>(
entriesCount: entriesCount,
prioritizedEntries: assignmentState.prioritizedEntries,
availableMatchups: optimalAvailableMatchups
Expand All @@ -78,7 +78,7 @@ extension LeagueScheduleData {
}*/
guard let originalPair = selectMatchup(prioritizedMatchups: prioritizedMatchups) else { return false }
var matchup = originalPair
matchup.balanceHomeAway(assignmentState: assignmentState)
matchup.balanceHomeAway(rng: &rng, assignmentState: assignmentState)
// successfully selected a matchup
guard let _ = assignMatchupPair(
matchup,
Expand All @@ -87,9 +87,9 @@ extension LeagueScheduleData {
canPlayAt: canPlayAt
) else {
// failed to assign matchup, skip it for now
failedMatchupSelections[unchecked: assignmentIndex].insert(originalPair)
failedMatchupSelections[unchecked: assignmentIndex].insertMember(originalPair)
prioritizedMatchups.remove(originalPair)
assignmentState.availableMatchups.remove(originalPair)
assignmentState.availableMatchups.removeMember(originalPair)
continue
}
// successfully assigned pair
Expand Down Expand Up @@ -117,7 +117,7 @@ extension LeagueScheduleData {
availableMatchups: optimalAvailableMatchups
)
}
assignmentState.availableMatchups.remove(originalPair)
assignmentState.availableMatchups.removeMember(originalPair)
}
return assignmentState.matchups.count == expectedMatchupsCount
}
Expand All @@ -136,19 +136,19 @@ extension LeagueScheduleData {
}
// TODO: pick the optimal combination that should be selected?
combinationLoop: for combination in allowedDivisionCombinations {
var assignedSlots = Set<AvailableSlot>()
var combinationTimeAllocations:ContiguousArray<Set<TimeIndex>> = .init(
repeating: Set(minimumCapacity: defaultMaxEntryMatchupsPerGameDay),
var assignedSlots = Config.AvailableSlotSet()
var combinationTimeAllocations:ContiguousArray<Config.TimeSet> = .init(
repeating: .init(minimumCapacity: Int(defaultMaxEntryMatchupsPerGameDay)),
count: combination.first?.count ?? 10
)
for (divisionIndex, divisionCombination) in combination.enumerated() {
let division = Division.IDValue(divisionIndex)
let divisionMatchups = assignmentState.allDivisionMatchups[unchecked: division]
assignmentState.availableMatchups = divisionMatchups
assignmentState.prioritizedEntries.removeAll(keepingCapacity: true)
for matchup in assignmentState.availableMatchups {
assignmentState.prioritizedEntries.insert(matchup.team1)
assignmentState.prioritizedEntries.insert(matchup.team2)
assignmentState.prioritizedEntries.removeAllKeepingCapacity()
assignmentState.availableMatchups.forEach { matchup in
assignmentState.prioritizedEntries.insertMember(matchup.team1)
assignmentState.prioritizedEntries.insertMember(matchup.team2)
}
assignmentState.recalculateAllRemainingAllocations(
day: day,
Expand All @@ -159,7 +159,7 @@ extension LeagueScheduleData {
#if LOG
print("assignSlots;b2b;division=\(division);divisionCombination=\(divisionCombination);matchups.count=\(assignmentState.matchups.count);availableSlots=\(assignmentState.availableSlots.map({ $0.description }));remainingAllocations=\(assignmentState.remainingAllocations.map { $0.map({ $0.description }) })")
#endif
var disallowedTimes = Set<TimeIndex>(minimumCapacity: defaultMaxEntryMatchupsPerGameDay)
var disallowedTimes = Config.TimeSet(minimumCapacity: Int(defaultMaxEntryMatchupsPerGameDay))
for (divisionCombinationIndex, amount) in divisionCombination.enumerated() {
guard amount > 0 else { continue }
let combinationTimeAllocation = combinationTimeAllocations[divisionCombinationIndex]
Expand Down Expand Up @@ -188,10 +188,10 @@ extension LeagueScheduleData {
#endif
continue combinationLoop
}
for matchup in matchups {
disallowedTimes.insert(matchup.time)
combinationTimeAllocations[divisionCombinationIndex].insert(matchup.time)
assignedSlots.insert(matchup.slot)
matchups.forEach { matchup in
disallowedTimes.insertMember(matchup.time)
combinationTimeAllocations[divisionCombinationIndex].insertMember(matchup.time)
assignedSlots.insertMember(matchup.slot)
}
assignmentState.availableSlots = slots.filter { !disallowedTimes.contains($0.time) }
assignmentState.recalculateAvailableMatchups(
Expand Down Expand Up @@ -245,30 +245,31 @@ extension LeagueScheduleData {
gameGap: GameGap.TupleValue,
entryMatchupsPerGameDay: EntryMatchupsPerGameDay,
divisionRecurringDayLimitInterval: ContiguousArray<RecurringDayLimitInterval>,
allAvailableMatchups: Set<MatchupPair>,
assignmentState: inout AssignmentState,
allAvailableMatchups: Config.MatchupPairSet,
rng: inout some RandomNumberGenerator,
assignmentState: inout AssignmentState<Config>,
shouldSkipSelection: (MatchupPair) -> Bool,
selectSlot: borrowing some SelectSlotProtocol & ~Copyable,
canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable
) -> Matchup? {
var pair:MatchupPair? = nil
var prioritizedMatchups = PrioritizedMatchups(
var prioritizedMatchups = PrioritizedMatchups<Config>(
entriesCount: entriesCount,
prioritizedEntries: assignmentState.prioritizedEntries,
availableMatchups: assignmentState.availableMatchups
)
while pair == nil {
guard let selected = assignmentState.selectMatchup(prioritizedMatchups: prioritizedMatchups) else { return nil }
guard let selected = assignmentState.selectMatchup(prioritizedMatchups: prioritizedMatchups, rng: &rng) else { return nil }
if !shouldSkipSelection(selected) {
pair = selected
prioritizedMatchups.update(prioritizedEntries: assignmentState.prioritizedEntries, availableMatchups: assignmentState.availableMatchups)
} else {
prioritizedMatchups.remove(selected)
assignmentState.availableMatchups.remove(selected)
assignmentState.availableMatchups.removeMember(selected)
}
}
guard var pair else { return nil }
pair.balanceHomeAway(assignmentState: assignmentState)
pair.balanceHomeAway(rng: &rng, assignmentState: assignmentState)

#if LOG
print("AssignSlots;selectAndAssignMatchup;pair=\(pair);remainingAllocations[team1]=\(assignmentState.remainingAllocations[unchecked: pair.team1].map({ $0.description }));remainingAllocations[team2]=\(assignmentState.remainingAllocations[unchecked: pair.team2].map({ $0.description }))")
Expand All @@ -295,23 +296,24 @@ extension LeagueScheduleData {
gameGap: GameGap.TupleValue,
entryMatchupsPerGameDay: EntryMatchupsPerGameDay,
divisionRecurringDayLimitInterval: ContiguousArray<RecurringDayLimitInterval>,
allAvailableMatchups: Set<MatchupPair>,
assignmentState: inout AssignmentState,
allAvailableMatchups: Config.MatchupPairSet,
rng: inout some RandomNumberGenerator,
assignmentState: inout AssignmentState<Config>,
selectSlot: borrowing some SelectSlotProtocol & ~Copyable,
canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable
) -> Matchup? {
var pair:MatchupPair? = nil
var prioritizedMatchups = PrioritizedMatchups(
var prioritizedMatchups = PrioritizedMatchups<Config>(
entriesCount: entriesCount,
prioritizedEntries: assignmentState.prioritizedEntries,
availableMatchups: assignmentState.availableMatchups
)
while pair == nil {
guard let selected = assignmentState.selectMatchup(prioritizedMatchups: prioritizedMatchups) else { return nil }
guard let selected = assignmentState.selectMatchup(prioritizedMatchups: prioritizedMatchups, rng: &rng) else { return nil }
pair = selected
}
guard var pair else { return nil }
pair.balanceHomeAway(assignmentState: assignmentState)
pair.balanceHomeAway(rng: &rng, assignmentState: assignmentState)

#if LOG
print("AssignSlots;selectAndAssignMatchup;pair=\(pair);remainingAllocations[team1]=\(assignmentState.remainingAllocations[unchecked: pair.team1].map({ $0.description }));remainingAllocations[team2]=\(assignmentState.remainingAllocations[unchecked: pair.team2].map({ $0.description }))")
Expand Down
Loading