Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
81f4cce
initially adoption of `BitSet64` to improve performance
RandomHashTags Mar 6, 2026
1944313
bitset & canPlayAt changes; added `AbstractSet` and `BitSet128`
RandomHashTags Mar 6, 2026
aa4868b
breaking changes
RandomHashTags Mar 6, 2026
fe69291
nonsense
RandomHashTags Mar 7, 2026
239b945
move some stuff around
RandomHashTags Mar 7, 2026
e48b6aa
handle generics better
RandomHashTags Mar 7, 2026
3121865
remove `PlaysAtTimes` and `PlaysAtLocations` typealiases
RandomHashTags Mar 7, 2026
fd1bf13
finally got the bit sets to perform properly
RandomHashTags Mar 7, 2026
c5361f4
use bit sets for `Set<LeagueDayIndex>` if possible
RandomHashTags Mar 7, 2026
35fcb43
use a bit set for entry ids if possible
RandomHashTags Mar 8, 2026
3e5647a
don't explode the binary and compile time; add package trait to toggl…
RandomHashTags Mar 8, 2026
0649ac1
bug fix for `SelectSlotEarliestTimeAndSameLocationIfB2B`
RandomHashTags Mar 8, 2026
521531f
disable `testthroughput` unit test
RandomHashTags Mar 9, 2026
3644943
add `ProtobufCodable` package trait
RandomHashTags Mar 16, 2026
1fe86cd
bonk
RandomHashTags Mar 16, 2026
31602f1
resolving merge conflicts
RandomHashTags Mar 16, 2026
c3bff2d
resolving merge conflicts
RandomHashTags Mar 16, 2026
6ae650c
resolving merge conflicts
RandomHashTags Mar 16, 2026
bc69ff8
resolving merge conflicts
RandomHashTags Mar 16, 2026
e6fa73f
Merge branch 'main' into adopt-bitsets
RandomHashTags Mar 16, 2026
a06881b
resolve merge conflicts
RandomHashTags Mar 16, 2026
cc5b056
apply some missing changes
RandomHashTags Mar 16, 2026
f279864
replace `SetOfDayIndexes`, `SetOfTimeIndexes` and `SetOfLocationIndex…
RandomHashTags Mar 16, 2026
7c49048
add bit set unit tests
RandomHashTags Mar 17, 2026
4c3a020
revert minor performance change when selecting b2b slots
RandomHashTags Mar 17, 2026
493e893
use `Config.TimeSet` when assigning b2b slots
RandomHashTags Mar 17, 2026
15798db
unit test minor fixes
RandomHashTags Mar 17, 2026
ea356fa
remove `PlaysAtTimes` and `PlaysAtLocations` typealiases
RandomHashTags Mar 17, 2026
5610296
add unit tests for bit set `removeAll(where:)`
RandomHashTags Mar 17, 2026
8a2c5b4
improve spacing for bit set `removeAll(where:)` unit tests
RandomHashTags Mar 17, 2026
f06933b
fallback to the default generation constraints
RandomHashTags Mar 17, 2026
344d233
2 `MatchupBlock` fixes
RandomHashTags Mar 17, 2026
5bd63fa
logic fix when calculating bit set `availableMatchupPairs`
RandomHashTags Mar 18, 2026
2366e56
add, and incorporate `ScheduleConfiguration`, the new balance home/aw…
RandomHashTags Mar 18, 2026
2e25044
Merge branch 'main' into adopt-bitsets
RandomHashTags Mar 18, 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
8 changes: 5 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ let package = Package(
traits: [
.default(enabledTraits: [
"ProtobufCodable",
"UncheckedArraySubscript"
"UncheckedArraySubscript",
"SpecializeScheduleConfiguration"
]),
.trait(name: "ProtobufCodable"),
.trait(name: "UncheckedArraySubscript")
.trait(name: "UncheckedArraySubscript"),
.trait(name: "SpecializeScheduleConfiguration")
],
dependencies: [
.package(url: "https://github.com/RandomHashTags/swift-staticdatetime", from: "0.3.5"),
Expand All @@ -39,4 +41,4 @@ let package = Package(
dependencies: ["LeagueScheduling"]
)
]
)
)
119 changes: 5 additions & 114 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 Down Expand Up @@ -123,115 +123,6 @@ extension LeagueScheduleData {
}
}

// MARK: Assign slots b2b
extension LeagueScheduleData {
private mutating func assignSlotsB2B(
canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable
) throws(LeagueError) -> Bool {
let slots = assignmentState.availableSlots
let assignmentStateCopy = assignmentState.copy()
whileLoop: while assignmentState.matchups.count != expectedMatchupsCount {
if Task.isCancelled {
throw LeagueError.timedOut(function: "assignSlotsB2B")
}
// 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),
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.recalculateAllRemainingAllocations(
day: day,
entriesCount: entriesCount,
gameGap: gameGap,
canPlayAt: canPlayAt
)
#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)
for (divisionCombinationIndex, amount) in divisionCombination.enumerated() {
guard amount > 0 else { continue }
let combinationTimeAllocation = combinationTimeAllocations[divisionCombinationIndex]
if !combinationTimeAllocation.isEmpty {
assignmentState.availableSlots = slots.filter { combinationTimeAllocation.contains($0.time) }
assignmentState.recalculateAvailableMatchups(
day: day,
entryMatchupsPerGameDay: defaultMaxEntryMatchupsPerGameDay,
allAvailableMatchups: divisionMatchups
)
assignmentState.recalculateAllRemainingAllocations(
day: day,
entriesCount: entriesCount,
gameGap: gameGap,
canPlayAt: canPlayAt
)
}
guard let matchups = assignBlockOfMatchups(
amount: amount,
division: division,
canPlayAt: canPlayAt
) else {
assignmentState = assignmentStateCopy.copy()
#if LOG
print("assignSlotsB2B;failed to assign matchups for division \(division) and combination \(divisionCombination);skipping")
#endif
continue combinationLoop
}
for matchup in matchups {
disallowedTimes.insert(matchup.time)
combinationTimeAllocations[divisionCombinationIndex].insert(matchup.time)
assignedSlots.insert(matchup.slot)
}
assignmentState.availableSlots = slots.filter { !disallowedTimes.contains($0.time) }
assignmentState.recalculateAvailableMatchups(
day: day,
entryMatchupsPerGameDay: defaultMaxEntryMatchupsPerGameDay,
allAvailableMatchups: divisionMatchups
)
assignmentState.recalculateAllRemainingAllocations(
day: day,
entriesCount: entriesCount,
gameGap: gameGap,
canPlayAt: canPlayAt
)
#if LOG
print("assignSlots;b2b;combination=\(divisionCombination);assigned \(amount) for division \(division);availableSlots=\(assignmentState.availableSlots.map({ "\($0)" }))")
#endif
// successfully assigned matchup block of <amount> for <division>
}
assignmentState.availableSlots = slots.filter { !assignedSlots.contains($0) }
assignmentState.recalculateAllRemainingAllocations(
day: day,
entriesCount: entriesCount,
gameGap: gameGap,
canPlayAt: canPlayAt
)
#if LOG
print("assignSlots;b2b;assigned \(divisionCombination) for division \(division)")
#endif
}
break whileLoop
}
return false
}
#if LOG
print("assignSlotsB2B;assignmentState.matchups.count=\(assignmentState.matchups.count);expectedMatchupsCount=\(expectedMatchupsCount)")
#endif
return assignmentState.matchups.count == expectedMatchupsCount
}
}

// MARK: Select and assign matchup
extension LeagueScheduleData {
/// Selects and assigns a matchup to an available slot.
Expand All @@ -246,13 +137,13 @@ extension LeagueScheduleData {
entryMatchupsPerGameDay: EntryMatchupsPerGameDay,
divisionRecurringDayLimitInterval: ContiguousArray<RecurringDayLimitInterval>,
allAvailableMatchups: Set<MatchupPair>,
assignmentState: inout AssignmentState,
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
Expand Down Expand Up @@ -296,12 +187,12 @@ extension LeagueScheduleData {
entryMatchupsPerGameDay: EntryMatchupsPerGameDay,
divisionRecurringDayLimitInterval: ContiguousArray<RecurringDayLimitInterval>,
allAvailableMatchups: Set<MatchupPair>,
assignmentState: inout AssignmentState,
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
Expand Down
108 changes: 108 additions & 0 deletions Sources/league-scheduling/data/AssignSlotsB2B.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@

extension LeagueScheduleData {
mutating func assignSlotsB2B(
canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable
) throws(LeagueError) -> Bool {
let slots = assignmentState.availableSlots
let assignmentStateCopy = assignmentState.copy()
whileLoop: while assignmentState.matchups.count != expectedMatchupsCount {
if Task.isCancelled {
throw LeagueError.timedOut(function: "assignSlotsB2B")
}
// TODO: pick the optimal combination that should be selected?
combinationLoop: for combination in allowedDivisionCombinations {
var assignedSlots = Set<AvailableSlot>()
var combinationTimeAllocations:ContiguousArray<Config.TimeSet> = .init(
repeating: .init(),
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.removeAllKeepingCapacity()
for matchup in assignmentState.availableMatchups {
assignmentState.prioritizedEntries.insertMember(matchup.team1)
assignmentState.prioritizedEntries.insertMember(matchup.team2)
}
assignmentState.recalculateAllRemainingAllocations(
day: day,
entriesCount: entriesCount,
gameGap: gameGap,
canPlayAt: canPlayAt
)
#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 = Config.TimeSet()
for (divisionCombinationIndex, amount) in divisionCombination.enumerated() {
guard amount > 0 else { continue }
let combinationTimeAllocation = combinationTimeAllocations[divisionCombinationIndex]
if !combinationTimeAllocation.isEmpty {
assignmentState.availableSlots = slots.filter { combinationTimeAllocation.contains($0.time) }
assignmentState.recalculateAvailableMatchups(
day: day,
entryMatchupsPerGameDay: defaultMaxEntryMatchupsPerGameDay,
allAvailableMatchups: divisionMatchups
)
assignmentState.recalculateAllRemainingAllocations(
day: day,
entriesCount: entriesCount,
gameGap: gameGap,
canPlayAt: canPlayAt
)
}
guard let matchups = assignBlockOfMatchups(
amount: amount,
division: division,
canPlayAt: canPlayAt
) else {
assignmentState = assignmentStateCopy.copy()
#if LOG
print("assignSlotsB2B;failed to assign matchups for division \(division) and combination \(divisionCombination);skipping")
#endif
continue combinationLoop
}
for matchup in matchups {
disallowedTimes.insertMember(matchup.time)
combinationTimeAllocations[divisionCombinationIndex].insertMember(matchup.time)
assignedSlots.insert(matchup.slot)
}
assignmentState.availableSlots = slots.filter { !disallowedTimes.contains($0.time) }
assignmentState.recalculateAvailableMatchups(
day: day,
entryMatchupsPerGameDay: defaultMaxEntryMatchupsPerGameDay,
allAvailableMatchups: divisionMatchups
)
assignmentState.recalculateAllRemainingAllocations(
day: day,
entriesCount: entriesCount,
gameGap: gameGap,
canPlayAt: canPlayAt
)
#if LOG
print("assignSlots;b2b;combination=\(divisionCombination);assigned \(amount) for division \(division);availableSlots=\(assignmentState.availableSlots.map({ "\($0)" }))")
#endif
// successfully assigned matchup block of <amount> for <division>
}
assignmentState.availableSlots = slots.filter { !assignedSlots.contains($0) }
assignmentState.recalculateAllRemainingAllocations(
day: day,
entriesCount: entriesCount,
gameGap: gameGap,
canPlayAt: canPlayAt
)
#if LOG
print("assignSlots;b2b;assigned \(divisionCombination) for division \(division)")
#endif
}
break whileLoop
}
return false
}
#if LOG
print("assignSlotsB2B;assignmentState.matchups.count=\(assignmentState.matchups.count);expectedMatchupsCount=\(expectedMatchupsCount)")
#endif
return assignmentState.matchups.count == expectedMatchupsCount
}
}
22 changes: 15 additions & 7 deletions Sources/league-scheduling/data/BalanceHomeAway.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
// MARK: Matchup pair
extension MatchupPair {
/// Balances home/away allocations, mutating `team1` (home) and `team2` (away) if necessary.
mutating func balanceHomeAway(
assignmentState: borrowing AssignmentState
#if SpecializeScheduleConfiguration
@_specialize(where Config == ScheduleConfig<BitSet64<DayIndex>, BitSet64<TimeIndex>, BitSet64<LocationIndex>, BitSet64<Entry.IDValue>>)
@_specialize(where Config == ScheduleConfig<Set<DayIndex>, Set<TimeIndex>, Set<LocationIndex>, Set<Entry.IDValue>>)
#endif
mutating func balanceHomeAway<Config: ScheduleConfiguration>(
assignmentState: borrowing AssignmentState<Config>
) {
let team1GamesPlayedAgainstTeam2 = assignmentState.assignedEntryHomeAways[unchecked: team1][unchecked: team2]
// TODO: fix; more/less opponents than game days can make this unbalanced
Expand Down Expand Up @@ -43,6 +47,10 @@ extension MatchupPair {

// MARK: LeagueScheduleData
extension LeagueScheduleData {
#if SpecializeScheduleConfiguration
@_specialize(where Config == ScheduleConfig<BitSet64<DayIndex>, BitSet64<TimeIndex>, BitSet64<LocationIndex>, BitSet64<Entry.IDValue>>)
@_specialize(where Config == ScheduleConfig<Set<DayIndex>, Set<TimeIndex>, Set<LocationIndex>, Set<Entry.IDValue>>)
#endif
mutating func balanceHomeAway(
generationData: inout LeagueGenerationData
) {
Expand All @@ -52,7 +60,7 @@ extension LeagueScheduleData {
#endif

let now = clock.now
var unbalancedEntryIDs = Set<Entry.IDValue>()
var unbalancedEntryIDs = Config.EntryIDSet()
unbalancedEntryIDs.reserveCapacity(entriesCount)
var neededFlipsToBalance = [(home: UInt8, away: UInt8)](repeating: (0, 0), count: entriesCount)
for entryID in 0..<Entry.IDValue(entriesCount) {
Expand All @@ -61,7 +69,7 @@ extension LeagueScheduleData {
guard home != away && (home + away) % 2 == 0 else {
continue
}
unbalancedEntryIDs.insert(entryID)
unbalancedEntryIDs.insertMember(entryID)
let balanceNumber = (home + away) / 2
if home > balanceNumber {
neededFlipsToBalance[unchecked: entryID].home = home - balanceNumber
Expand Down Expand Up @@ -102,14 +110,14 @@ extension LeagueScheduleData {
flippable.remove(flipped)
flipHomeAway(matchup: &flipped, neededFlipsToBalance: &neededFlipsToBalance, generationData: &generationData)
if neededFlipsToBalance[unchecked: flipped.matchup.home] == (0, 0) {
unbalancedEntryIDs.remove(flipped.matchup.home)
unbalancedEntryIDs.removeMember(flipped.matchup.home)
}
if neededFlipsToBalance[unchecked: flipped.matchup.away] == (0, 0) {
unbalancedEntryIDs.remove(flipped.matchup.away)
unbalancedEntryIDs.removeMember(flipped.matchup.away)
}
} else {
// TODO: improve? for now we can just skip it
unbalancedEntryIDs.remove(entryID)
unbalancedEntryIDs.removeMember(entryID)
}
}

Expand Down
Loading