diff --git a/Package.swift b/Package.swift index 5a12c19..7a35a52 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,9 @@ 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"), ], @@ -28,6 +31,7 @@ let package = Package( .target( name: "LeagueScheduling", dependencies: [ + .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "StaticDateTimes", package: "swift-staticdatetime"), .product(name: "SwiftProtobuf", package: "swift-protobuf") ], diff --git a/Sources/ProtocolBuffers/Determinism.proto b/Sources/ProtocolBuffers/Determinism.proto new file mode 100644 index 0000000..86f919d --- /dev/null +++ b/Sources/ProtocolBuffers/Determinism.proto @@ -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; +} \ No newline at end of file diff --git a/Sources/ProtocolBuffers/GenerationConstraints.proto b/Sources/ProtocolBuffers/GenerationConstraints.proto index 19521b1..3227e36 100644 --- a/Sources/ProtocolBuffers/GenerationConstraints.proto +++ b/Sources/ProtocolBuffers/GenerationConstraints.proto @@ -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). @@ -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; } \ No newline at end of file diff --git a/Sources/league-scheduling/data/AssignMatchup.swift b/Sources/league-scheduling/data/AssignMatchup.swift index d819b4e..54320c8 100644 --- a/Sources/league-scheduling/data/AssignMatchup.swift +++ b/Sources/league-scheduling/data/AssignMatchup.swift @@ -5,7 +5,7 @@ extension LeagueScheduleData { @discardableResult mutating func assignMatchupPair( _ pair: MatchupPair, - allAvailableMatchups: Set, + allAvailableMatchups: Config.MatchupPairSet, selectSlot: borrowing some SelectSlotProtocol & ~Copyable, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) -> Matchup? { @@ -34,7 +34,7 @@ extension AssignmentState { gameGap: GameGap.TupleValue, entryMatchupsPerGameDay: EntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: ContiguousArray, - allAvailableMatchups: Set, + allAvailableMatchups: Config.MatchupPairSet, selectSlot: borrowing some SelectSlotProtocol & ~Copyable, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) -> Matchup? { @@ -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 { + func playableSlots(for pair: MatchupPair) -> Config.AvailableSlotSet { return Self.playableSlots(for: pair, remainingAllocations: remainingAllocations) } static func playableSlots( for pair: MatchupPair, - remainingAllocations: RemainingAllocations - ) -> Set { + remainingAllocations: Config.RemainingAllocations + ) -> Config.AvailableSlotSet { return remainingAllocations[unchecked: pair.team1].intersection(remainingAllocations[unchecked: pair.team2]) } } \ No newline at end of file diff --git a/Sources/league-scheduling/data/AssignSlots.swift b/Sources/league-scheduling/data/AssignSlots.swift index fa21b7b..fc18d75 100644 --- a/Sources/league-scheduling/data/AssignSlots.swift +++ b/Sources/league-scheduling/data/AssignSlots.swift @@ -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( entriesCount: entriesCount, prioritizedEntries: assignmentState.prioritizedEntries, availableMatchups: optimalAvailableMatchups @@ -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, @@ -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 @@ -117,7 +117,7 @@ extension LeagueScheduleData { availableMatchups: optimalAvailableMatchups ) } - assignmentState.availableMatchups.remove(originalPair) + assignmentState.availableMatchups.removeMember(originalPair) } return assignmentState.matchups.count == expectedMatchupsCount } @@ -136,19 +136,19 @@ extension LeagueScheduleData { } // TODO: pick the optimal combination that should be selected? combinationLoop: for combination in allowedDivisionCombinations { - var assignedSlots = Set() - var combinationTimeAllocations:ContiguousArray> = .init( - repeating: Set(minimumCapacity: defaultMaxEntryMatchupsPerGameDay), + var assignedSlots = Config.AvailableSlotSet() + var combinationTimeAllocations:ContiguousArray = .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, @@ -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(minimumCapacity: defaultMaxEntryMatchupsPerGameDay) + var disallowedTimes = Config.TimeSet(minimumCapacity: Int(defaultMaxEntryMatchupsPerGameDay)) for (divisionCombinationIndex, amount) in divisionCombination.enumerated() { guard amount > 0 else { continue } let combinationTimeAllocation = combinationTimeAllocations[divisionCombinationIndex] @@ -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( @@ -245,30 +245,31 @@ extension LeagueScheduleData { gameGap: GameGap.TupleValue, entryMatchupsPerGameDay: EntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: ContiguousArray, - allAvailableMatchups: Set, - assignmentState: inout AssignmentState, + allAvailableMatchups: Config.MatchupPairSet, + rng: inout some RandomNumberGenerator, + assignmentState: inout AssignmentState, shouldSkipSelection: (MatchupPair) -> Bool, selectSlot: borrowing some SelectSlotProtocol & ~Copyable, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) -> Matchup? { var pair:MatchupPair? = nil - var prioritizedMatchups = PrioritizedMatchups( + var prioritizedMatchups = PrioritizedMatchups( 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 }))") @@ -295,23 +296,24 @@ extension LeagueScheduleData { gameGap: GameGap.TupleValue, entryMatchupsPerGameDay: EntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: ContiguousArray, - allAvailableMatchups: Set, - assignmentState: inout AssignmentState, + allAvailableMatchups: Config.MatchupPairSet, + rng: inout some RandomNumberGenerator, + assignmentState: inout AssignmentState, selectSlot: borrowing some SelectSlotProtocol & ~Copyable, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) -> Matchup? { var pair:MatchupPair? = nil - var prioritizedMatchups = PrioritizedMatchups( + var prioritizedMatchups = PrioritizedMatchups( 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 }))") diff --git a/Sources/league-scheduling/data/BalanceHomeAway.swift b/Sources/league-scheduling/data/BalanceHomeAway.swift index c264322..7226d6b 100644 --- a/Sources/league-scheduling/data/BalanceHomeAway.swift +++ b/Sources/league-scheduling/data/BalanceHomeAway.swift @@ -2,15 +2,16 @@ // MARK: Matchup pair extension MatchupPair { /// Balances home/away allocations, mutating `team1` (home) and `team2` (away) if necessary. - mutating func balanceHomeAway( - assignmentState: borrowing AssignmentState + mutating func balanceHomeAway( + rng: inout some RandomNumberGenerator, + assignmentState: borrowing AssignmentState ) { let team1GamesPlayedAgainstTeam2 = assignmentState.assignedEntryHomeAways[unchecked: team1][unchecked: team2] // TODO: fix; more/less opponents than game days can make this unbalanced if team1GamesPlayedAgainstTeam2.home < team1GamesPlayedAgainstTeam2.away { // keep `team1` at home and `team2` at away } else if team1GamesPlayedAgainstTeam2.home == team1GamesPlayedAgainstTeam2.away { - if Self.shouldPlayAtHome(team1: team1, team2: team2, homeMatchups: assignmentState.homeMatchups, awayMatchups: assignmentState.awayMatchups) { + if Self.shouldPlayAtHome(team1: team1, team2: team2, homeMatchups: assignmentState.homeMatchups, awayMatchups: assignmentState.awayMatchups, rng: &rng) { // keep `team1` at home and `team2` at away return } @@ -28,7 +29,8 @@ extension MatchupPair { team1: Entry.IDValue, team2: Entry.IDValue, homeMatchups: [UInt8], - awayMatchups: [UInt8] + awayMatchups: [UInt8], + rng: inout some RandomNumberGenerator ) -> Bool { let home1 = homeMatchups[unchecked: team1] let home2 = homeMatchups[unchecked: team2] @@ -37,7 +39,7 @@ extension MatchupPair { let away1 = awayMatchups[unchecked: team1] let away2 = awayMatchups[unchecked: team2] guard away1 == away2 else { return away1 < away2 } - return Bool.random() + return Bool.random(using: &rng) } } @@ -52,7 +54,7 @@ extension LeagueScheduleData { #endif let now = clock.now - var unbalancedEntryIDs = Set() + var unbalancedEntryIDs = Config.EntryIDSet() unbalancedEntryIDs.reserveCapacity(entriesCount) var neededFlipsToBalance = [(home: UInt8, away: UInt8)](repeating: (0, 0), count: entriesCount) for entryID in 0.. balanceNumber { neededFlipsToBalance[unchecked: entryID].home = home - balanceNumber @@ -73,43 +75,43 @@ extension LeagueScheduleData { appendExecutionStep(now: now) return } - var flippable = Set() + var flippable = Config.FlippableMatchupSet() for day in 0.. 0 { flipped = flippable.filter({ $0.matchup.home == entryID && neededFlipsToBalance[unchecked: $0.matchup.home].home > 0 && neededFlipsToBalance[unchecked: $0.matchup.away].away > 0 - }).randomElement() + }).randomElement(using: &rng) } else { flipped = flippable.filter({ $0.matchup.away == entryID && neededFlipsToBalance[unchecked: $0.matchup.home].home > 0 && neededFlipsToBalance[unchecked: $0.matchup.away].away > 0 - }).randomElement() + }).randomElement(using: &rng) } if var flipped { - flippable.remove(flipped) + flippable.removeMember(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) } } @@ -143,11 +145,7 @@ extension LeagueScheduleData { matchup.matchup.home = away matchup.matchup.away = home - generationData.schedule[unchecked: matchup.day].insert(matchup.matchup) - } - private struct FlippableMatchup: Hashable, Sendable { - let day:DayIndex - var matchup:Matchup + generationData.schedule[unchecked: matchup.day].insertMember(matchup.matchup) } private mutating func appendExecutionStep(now: ContinuousClock.Instant) { diff --git a/Sources/league-scheduling/data/DivisionMatchupCombinations.swift b/Sources/league-scheduling/data/DivisionMatchupCombinations.swift index 3d53744..87a9316 100644 --- a/Sources/league-scheduling/data/DivisionMatchupCombinations.swift +++ b/Sources/league-scheduling/data/DivisionMatchupCombinations.swift @@ -1,110 +1,106 @@ // MARK: All combinations -extension LeagueScheduleData { - /// - Returns: All division matchup combinations separated by division. - /// - Usage: [`Division.IDValue`: `division matchup combinations`] - static func allDivisionMatchupCombinations( - entriesPerMatchup: EntriesPerMatchup, - locations: LocationIndex, - entryCountsForDivision: ContiguousArray - ) -> ContiguousArray>> { - var combinations:ContiguousArray>> = .init(repeating: [], count: entryCountsForDivision.count) - for (divisionIndex, entryCount) in entryCountsForDivision.enumerated() { - if entryCount > 0 { - let matchupsCount = entryCount / entriesPerMatchup - let upperLimit:Int - if matchupsCount > locations { // more available matchups than locations - upperLimit = Int(locations) - } else { - upperLimit = matchupsCount - } - for i in 0...upperLimit { - let right = matchupsCount - i - if i != 1 && right != 1 && right <= upperLimit { - var combo = ContiguousArray() - combo.append(i) - combo.append(right) - combinations[divisionIndex].append(combo) - } - } +/// - Returns: All division matchup combinations separated by division. +/// - Usage: [`Division.IDValue`: `division matchup combinations`] +func calculateAllDivisionMatchupCombinations( + entriesPerMatchup: EntriesPerMatchup, + locations: LocationIndex, + entryCountsForDivision: ContiguousArray +) -> ContiguousArray>> { + var combinations:ContiguousArray>> = .init(repeating: [], count: entryCountsForDivision.count) + for (divisionIndex, entryCount) in entryCountsForDivision.enumerated() { + if entryCount > 0 { + let matchupsCount = entryCount / entriesPerMatchup + let upperLimit:Int + if matchupsCount > locations { // more available matchups than locations + upperLimit = Int(locations) } else { - combinations[divisionIndex] = [] + upperLimit = matchupsCount } + for i in 0...upperLimit { + let right = matchupsCount - i + if i != 1 && right != 1 && right <= upperLimit { + var combo = ContiguousArray() + combo.append(i) + combo.append(right) + combinations[divisionIndex].append(combo) + } + } + } else { + combinations[divisionIndex] = [] } - return combinations } + return combinations } // MARK: Allowed combinations -extension LeagueScheduleData { - /// - Returns: Allowed division matchup combinations - /// - Usage: [`allowed matchup combination index`: [`Division.IDValue`: `division matchup combination`]] - static func allowedDivisionMatchupCombinations( - entriesPerMatchup: EntriesPerMatchup, - locations: LocationIndex, - entryCountsForDivision: ContiguousArray - ) -> ContiguousArray>> { - let allCombinations = allDivisionMatchupCombinations( - entriesPerMatchup: entriesPerMatchup, - locations: locations, - entryCountsForDivision: entryCountsForDivision - ) - var combinations = ContiguousArray>>() - guard let initialResultsCount = allCombinations.first?.first?.count else { return combinations } - var combinationBuilder = ContiguousArray>() - combinationBuilder.reserveCapacity(entryCountsForDivision.count) +/// - Returns: Allowed division matchup combinations +/// - Usage: [`allowed matchup combination index`: [`Division.IDValue`: `division matchup combination`]] +func calculateAllowedDivisionMatchupCombinations( + entriesPerMatchup: EntriesPerMatchup, + locations: LocationIndex, + entryCountsForDivision: ContiguousArray +) -> ContiguousArray>> { + let allCombinations = calculateAllDivisionMatchupCombinations( + entriesPerMatchup: entriesPerMatchup, + locations: locations, + entryCountsForDivision: entryCountsForDivision + ) + var combinations = ContiguousArray>>() + guard let initialResultsCount = allCombinations.first?.first?.count else { return combinations } + var combinationBuilder = ContiguousArray>() + combinationBuilder.reserveCapacity(entryCountsForDivision.count) + yieldAllowedCombinations( + allCombinations: allCombinations, + division: 0, + locations: locations, + results: .init(repeating: 0, count: initialResultsCount), + combinationBuilder: combinationBuilder + ) { + combinations.append($0) + } + return combinations +} +private func yieldAllowedCombinations( + allCombinations: ContiguousArray>>, + division: Division.IDValue, + locations: LocationIndex, + results: ContiguousArray, + combinationBuilder: ContiguousArray>, + yield: (_ combination: ContiguousArray>) -> Void +) { + guard let targetCombinations = allCombinations[uncheckedPositive: division] else { + yield(combinationBuilder) + return + } + guard !targetCombinations.isEmpty else { yieldAllowedCombinations( allCombinations: allCombinations, - division: 0, + division: division + 1, locations: locations, - results: .init(repeating: 0, count: initialResultsCount), - combinationBuilder: combinationBuilder - ) { - combinations.append($0) - } - return combinations + results: results, + combinationBuilder: combinationBuilder, + yield: yield + ) + return } - private static func yieldAllowedCombinations( - allCombinations: ContiguousArray>>, - division: Division.IDValue, - locations: LocationIndex, - results: ContiguousArray, - combinationBuilder: ContiguousArray>, - yield: (_ combination: ContiguousArray>) -> Void - ) { - guard let targetCombinations = allCombinations[uncheckedPositive: division] else { - yield(combinationBuilder) - return - } - guard !targetCombinations.isEmpty else { - yieldAllowedCombinations( - allCombinations: allCombinations, - division: division + 1, - locations: locations, - results: results, - combinationBuilder: combinationBuilder, - yield: yield - ) - return - } - combinationLoop: - for combination in targetCombinations { - let combined = zip(results, combination).map { $0 + $1 } - for value in combined { - if value > locations { - continue combinationLoop - } + combinationLoop: + for combination in targetCombinations { + let combined = zip(results, combination).map { $0 + $1 } + for value in combined { + if value > locations { + continue combinationLoop } - var builder = combinationBuilder - builder.append(combination) - yieldAllowedCombinations( - allCombinations: allCombinations, - division: division + 1, - locations: locations, - results: ContiguousArray(combined), - combinationBuilder: builder, - yield: yield - ) } + var builder = combinationBuilder + builder.append(combination) + yieldAllowedCombinations( + allCombinations: allCombinations, + division: division + 1, + locations: locations, + results: ContiguousArray(combined), + combinationBuilder: builder, + yield: yield + ) } } \ No newline at end of file diff --git a/Sources/league-scheduling/data/Generation.swift b/Sources/league-scheduling/data/Generation.swift index 918f9f6..85510db 100644 --- a/Sources/league-scheduling/data/Generation.swift +++ b/Sources/league-scheduling/data/Generation.swift @@ -1,4 +1,6 @@ +import OrderedCollections + #if canImport(SwiftGlibc) import SwiftGlibc #elseif canImport(Foundation) @@ -35,14 +37,9 @@ extension RequestPayload.Runtime { // MARK: Generate schedules extension RequestPayload.Runtime { private func generateSchedules() async throws -> [LeagueGenerationData] { - let divisionsCount = divisions.count - var divisionEntries:ContiguousArray> = .init(repeating: Set(), count: divisionsCount) #if LOG - print("LeagueSchedule;generateSchedules;divisionsCount=\(divisionsCount);entries.count=\(entries.count)") + print("LeagueSchedule;generateSchedules;entries.count=\(entries.count)") #endif - for entryIndex in 0.., + Set, + Set, + Set, + Set, + Set, + Set + >.self + ) + } + switch constraints.determinism.technique { + default: + let seed = constraints.determinism.hasSeed ? constraints.determinism.seed : 1 + let multiplier = constraints.determinism.hasMultiplier ? constraints.determinism.multiplier : 6364136223846793005 + let increment = constraints.determinism.hasIncrement ? constraints.determinism.increment : 1442695040888963407 + return try await generateSchedules( + maxStartingTimes: maxStartingTimes, + maxLocations: maxLocations, + rng: LCG( + seed: seed, + multiplier: multiplier, + increment: increment + ), + ScheduleConfig< + LCG, + OrderedSet, + OrderedSet, + OrderedSet, + OrderedSet, + OrderedSet, + OrderedSet, + OrderedSet + >.self + ) + } + } + private func generateSchedules( + maxStartingTimes: TimeIndex, + maxLocations: LocationIndex, + rng: Config.RNG, + _ config: Config.Type + ) async throws -> [LeagueGenerationData] { + var divisionEntries:ContiguousArray = .init(repeating: .init(), count: divisions.count) + for entryIndex in 0.. = .init( + rng: rng, maxStartingTimes: maxStartingTimes, startingTimes: general.startingTimes, maxLocations: maxLocations, @@ -75,12 +126,25 @@ extension RequestPayload.Runtime { locationTravelDurations: general.locationTravelDurations ?? .init(repeating: .init(repeating: 0, count: maxLocations), count: maxLocations), maxSameOpponentMatchups: maxSameOpponentMatchups ) - var grouped = [DayOfWeek:Set]() + return try await generateDivisionSchedulesInParallel( + divisionsCount: divisions.count, + divisionEntries: divisionEntries, + maxStartingTimes: maxStartingTimes, + maxLocations: maxLocations, + dataSnapshot: dataSnapshot + ) + } + private func generateDivisionSchedulesInParallel( + divisionsCount: Int, + divisionEntries: ContiguousArray, + maxStartingTimes: TimeIndex, + maxLocations: LocationIndex, + dataSnapshot: LeagueScheduleDataSnapshot + ) async throws -> [LeagueGenerationData] { + var grouped = [DayOfWeek:Config.EntryIDSet]() for (divisionID, division) in divisions.enumerated() { - grouped[DayOfWeek(division.dayOfWeek), default: []].formUnion(divisionEntries[divisionID]) + grouped[DayOfWeek(division.dayOfWeek), default: .init()].formUnion(divisionEntries[divisionID]) } - let finalMaxStartingTimes = maxStartingTimes - let finalMaxLocations = maxLocations guard constraints.timeoutDelay > 0 else { return await withTaskGroup { group in for (dow, scheduledEntries) in grouped { @@ -90,8 +154,8 @@ extension RequestPayload.Runtime { settings: self, dataSnapshot: dataSnapshot, divisionsCount: divisionsCount, - maxStartingTimes: finalMaxStartingTimes, - maxLocations: finalMaxLocations, + maxStartingTimes: maxStartingTimes, + maxLocations: maxLocations, scheduledEntries: scheduledEntries ) } @@ -116,8 +180,8 @@ extension RequestPayload.Runtime { settings: self, dataSnapshot: dataSnapshot, divisionsCount: divisionsCount, - maxStartingTimes: finalMaxStartingTimes, - maxLocations: finalMaxLocations, + maxStartingTimes: maxStartingTimes, + maxLocations: maxLocations, scheduledEntries: scheduledEntries ) } @@ -168,23 +232,23 @@ extension RequestPayload.Runtime { // MARK: Generate schedule extension RequestPayload.Runtime { - private static func generateSchedule( + private static func generateSchedule( dayOfWeek: DayOfWeek, settings: RequestPayload.Runtime, - dataSnapshot: LeagueScheduleDataSnapshot, + dataSnapshot: LeagueScheduleDataSnapshot, divisionsCount: Int, maxStartingTimes: TimeIndex, maxLocations: LocationIndex, - scheduledEntries: Set + scheduledEntries: Config.EntryIDSet ) -> LeagueGenerationData { let gameDays = settings.gameDays var generationData = LeagueGenerationData() generationData.assignLocationTimeRegenerationAttempts = 0 generationData.negativeDayIndexRegenerationAttempts = 0 - generationData.schedule = .init(repeating: Set(), count: gameDays) + generationData.schedule = .init(repeating: .init(), count: gameDays) var dataSnapshot = copy dataSnapshot - var gameDayDivisionEntries:ContiguousArray>> = .init(repeating: .init(repeating: Set(), count: divisionsCount), count: gameDays) + var gameDayDivisionEntries:ContiguousArray> = .init(repeating: .init(repeating: .init(), count: divisionsCount), count: gameDays) loadMaxAllocations( dataSnapshot: &dataSnapshot, gameDayDivisionEntries: &gameDayDivisionEntries, @@ -194,7 +258,7 @@ extension RequestPayload.Runtime { scheduledEntries: scheduledEntries ) - var snapshots = [LeagueScheduleDataSnapshot]() + var snapshots = [LeagueScheduleDataSnapshot]() snapshots.reserveCapacity(gameDays) var gameDayRegenerationAttempt:UInt32 = 0 var day:DayIndex = 0 @@ -204,7 +268,7 @@ extension RequestPayload.Runtime { if gameDaySettingValuesCount <= day { gameDaySettingValuesCount += 1 let daySettings = settings.daySettings[unchecked: day].general - let availableSlots = Self.availableSlots( + let availableSlots:Config.AvailableSlotSet = Self.availableSlots( times: daySettings.timeSlots, locations: daySettings.locations, locationTimeExclusivity: daySettings.locationTimeExclusivities @@ -276,7 +340,9 @@ extension RequestPayload.Runtime { data.loadSnapshot(todayData) } } else { - generationData.schedule[unchecked: day] = data.assignmentState.matchups + var set = Set(minimumCapacity: data.assignmentState.matchups.count) + data.assignmentState.matchups.forEach { set.insert($0) } // TODO: optimize + generationData.schedule[unchecked: day] = set snapshots.append(todayData) day += 1 gameDayRegenerationAttempt = 0 @@ -286,9 +352,9 @@ extension RequestPayload.Runtime { finalizeGenerationData(generationData: &generationData, data: data) return generationData } - private static func finalizeGenerationData( + private static func finalizeGenerationData( generationData: inout LeagueGenerationData, - data: borrowing LeagueScheduleData + data: borrowing LeagueScheduleData ) { generationData.executionSteps = data.executionSteps generationData.shuffleHistory = data.shuffleHistory @@ -297,15 +363,15 @@ extension RequestPayload.Runtime { // MARK: Load max allocations extension RequestPayload.Runtime { - static func loadMaxAllocations( - dataSnapshot: inout LeagueScheduleDataSnapshot, - gameDayDivisionEntries: inout ContiguousArray>>, + static func loadMaxAllocations( + dataSnapshot: inout LeagueScheduleDataSnapshot, + gameDayDivisionEntries: inout ContiguousArray>, settings: borrowing RequestPayload.Runtime, maxStartingTimes: TimeIndex, maxLocations: LocationIndex, - scheduledEntries: Set + scheduledEntries: Config.EntryIDSet ) { - for entryIndex in scheduledEntries { + scheduledEntries.forEach { entryIndex in let entry = settings.entries[unchecked: entryIndex] var maxPossiblePlayed:EntryMatchupsPerGameDay = 0 var maxStartingTimesPlayedAt = 0 @@ -339,7 +405,7 @@ extension RequestPayload.Runtime { } } maxLocationsPlayedAt = max(maxLocationsPlayedAt, playable) - gameDayDivisionEntries[unchecked: day][unchecked: entry.division].insert(entry.id) + gameDayDivisionEntries[unchecked: day][unchecked: entry.division].insertMember(entry.id) } maxStartingTimesPlayedAt = max(maxStartingTimesPlayedAt, 1) maxLocationsPlayedAt = max(maxLocationsPlayedAt, 1) @@ -413,19 +479,20 @@ extension RequestPayload.Runtime { // MARK: Get available slots extension RequestPayload.Runtime { - static func availableSlots( + static func availableSlots( times: TimeIndex, locations: LocationIndex, locationTimeExclusivity: [Set]? - ) -> Set { - var slots = Set(minimumCapacity: times * locations) + ) -> AvailableSlotSet { + var slots = AvailableSlotSet() + slots.reserveCapacity(Int(times) * locations) if let exclusivities = locationTimeExclusivity { for location in 0..( gameDays: DayIndex, entriesCount: Int, - divisionEntries: ContiguousArray>, + divisionEntries: ContiguousArray, divisions: [Division.Runtime] ) -> MaximumSameOpponentMatchups { var maxSameOpponentMatchups:MaximumSameOpponentMatchups = .init(repeating: .init(repeating: .max, count: entriesCount), count: entriesCount) for (divisionIndex, division) in divisions.enumerated() { let divisionEntries = divisionEntries[divisionIndex] let cap = division.maxSameOpponentMatchups - for entryID in divisionEntries { - for opponentEntryID in divisionEntries { + divisionEntries.forEach { entryID in + divisionEntries.forEach { opponentEntryID in maxSameOpponentMatchups[unchecked: entryID][unchecked: opponentEntryID] = cap } } diff --git a/Sources/league-scheduling/data/LeagueScheduleData.swift b/Sources/league-scheduling/data/LeagueScheduleData.swift index d6f001f..0bddfe6 100644 --- a/Sources/league-scheduling/data/LeagueScheduleData.swift +++ b/Sources/league-scheduling/data/LeagueScheduleData.swift @@ -3,8 +3,9 @@ import StaticDateTimes // MARK: Data /// Fundamental building block that keeps track of and enforces assignment rules when building the schedule. -struct LeagueScheduleData: Sendable, ~Copyable { +struct LeagueScheduleData: Sendable, ~Copyable { let clock = ContinuousClock() + var rng:Config.RNG let entriesPerMatchup:EntriesPerMatchup let entriesCount:Int let entryDivisions:ContiguousArray @@ -28,21 +29,22 @@ struct LeagueScheduleData: Sendable, ~Copyable { var allowedDivisionCombinations:ContiguousArray>> = [] /// - Usage: [`selection index` : `Set`] - var failedMatchupSelections:ContiguousArray> + var failedMatchupSelections:ContiguousArray - var assignmentState:AssignmentState + var assignmentState:AssignmentState var prioritizeEarlierTimes:Bool var executionSteps = [ExecutionStep]() var shuffleHistory = [LeagueShuffleAction]() - var redistributionData:RedistributionData? + var redistributionData:RedistributionData? var redistributedMatchups = false init( - snapshot: LeagueScheduleDataSnapshot + snapshot: LeagueScheduleDataSnapshot ) { //locations = snapshot.locations + rng = snapshot.rng entriesPerMatchup = snapshot.entriesPerMatchup entriesCount = snapshot.entriesCount entryDivisions = snapshot.entryDivisions @@ -62,8 +64,9 @@ struct LeagueScheduleData: Sendable, ~Copyable { // MARK: Snapshot extension LeagueScheduleData { - mutating func loadSnapshot(_ snapshot: LeagueScheduleDataSnapshot) { + mutating func loadSnapshot(_ snapshot: LeagueScheduleDataSnapshot) { //locations = snapshot.locations + rng = snapshot.rng divisionRecurringDayLimitInterval = snapshot.divisionRecurringDayLimitInterval day = snapshot.day defaultMaxEntryMatchupsPerGameDay = snapshot.defaultMaxEntryMatchupsPerGameDay @@ -77,7 +80,7 @@ extension LeagueScheduleData { shuffleHistory = snapshot.shuffleHistory } - func snapshot() -> LeagueScheduleDataSnapshot { + func snapshot() -> LeagueScheduleDataSnapshot { return .init(self) } } @@ -93,8 +96,8 @@ extension LeagueScheduleData { mutating func newDay( day: DayIndex, daySettings: GeneralSettings.Runtime, - divisionEntries: ContiguousArray>, - availableSlots: Set, + divisionEntries: ContiguousArray, + availableSlots: Config.AvailableSlotSet, settings: RequestPayload.Runtime, generationData: inout LeagueGenerationData ) throws(LeagueError) { @@ -108,11 +111,12 @@ extension LeagueScheduleData { self.prioritizeEarlierTimes = daySettings.prioritizeEarlierTimes self.gameGap = daySettings.gameGap.minMax self.sameLocationIfB2B = daySettings.sameLocationIfB2B - var availableMatchups = Set() - var prioritizedEntries = Set(minimumCapacity: entriesCount) + var availableMatchups = Config.MatchupPairSet() + var prioritizedEntries = Config.EntryIDSet() + prioritizedEntries.reserveCapacity(entriesCount) var entryCountsForDivision:ContiguousArray = .init(repeating: 0, count: divisionEntries.count) expectedMatchupsCount = 0 - assignmentState.allDivisionMatchups = .init(repeating: [], count: divisionEntries.count) + assignmentState.allDivisionMatchups = .init(repeating: .init(), count: divisionEntries.count) for (divisionIndex, var entriesInDivision) in divisionEntries.enumerated() { if !entriesInDivision.isEmpty { divisionRecurringDayLimitInterval[divisionIndex] = Self.recurringDayLimitInterval( @@ -120,12 +124,9 @@ extension LeagueScheduleData { entryMatchupsPerGameDay: defaultMaxEntryMatchupsPerGameDay ) - var iterator = entriesInDivision.makeIterator() - while let entryID = iterator.next() { - if assignmentState.numberOfAssignedMatchups[unchecked: entryID] >= daySettings.maximumPlayableMatchups[unchecked: entryID] { - entriesInDivision.remove(entryID) - } - } + entriesInDivision.removeAll(where: { entryID in + assignmentState.numberOfAssignedMatchups[unchecked: entryID] >= daySettings.maximumPlayableMatchups[unchecked: entryID] + }) entryCountsForDivision[divisionIndex] = entriesInDivision.count expectedMatchupsCount += (entriesInDivision.count * defaultMaxEntryMatchupsPerGameDay) / entriesPerMatchup @@ -133,7 +134,10 @@ extension LeagueScheduleData { #if LOG print("LeagueScheduleData;newDay;day=\(day);expectedMatchupsCount=\(expectedMatchupsCount);divisionIndex=\(divisionIndex);entryCountsForDivision=\(entriesInDivision.count);divisionRecurringDayLimitInterval=\(divisionRecurringDayLimitInterval[divisionIndex])") #endif - let availableDivisionMatchups = availableMatchupPairs(for: entriesInDivision) + let availableDivisionMatchups:Config.MatchupPairSet = entriesInDivision.availableMatchupPairs( + assignedEntryHomeAways: assignmentState.assignedEntryHomeAways, + maxSameOpponentMatchups: assignmentState.maxSameOpponentMatchups + ) self.assignmentState.allDivisionMatchups[divisionIndex] = availableDivisionMatchups availableMatchups.formUnion(availableDivisionMatchups) } @@ -142,7 +146,7 @@ extension LeagueScheduleData { assignmentState.availableSlots = availableSlots switch daySettings.gameGap { case .no: - allowedDivisionCombinations = Self.allowedDivisionMatchupCombinations( + allowedDivisionCombinations = calculateAllowedDivisionMatchupCombinations( entriesPerMatchup: entriesPerMatchup, locations: daySettings.locations, entryCountsForDivision: entryCountsForDivision @@ -150,17 +154,15 @@ extension LeagueScheduleData { default: break } - failedMatchupSelections = .init(repeating: Set(), count: expectedMatchupsCount) + failedMatchupSelections = .init(repeating: .init(), count: expectedMatchupsCount) assignmentState.allMatchups = availableMatchups assignmentState.availableMatchups = availableMatchups assignmentState.prioritizedEntries = prioritizedEntries - assignmentState.matchups = Set(minimumCapacity: availableSlots.count) + assignmentState.matchups = Config.MatchupSet(minimumCapacity: availableSlots.count) for i in 0.. - ) -> Set { - return Self.availableMatchupPairs( - for: entries, - assignedEntryHomeAways: assignmentState.assignedEntryHomeAways, - maxSameOpponentMatchups: assignmentState.maxSameOpponentMatchups - ) - } - - /// - Parameters: - /// - entries: Entries that will participate in matchup scheduling. - /// - Returns: The available matchup pairs that can play for the `day`. - static func availableMatchupPairs( - for entries: Set, - assignedEntryHomeAways: AssignedEntryHomeAways, - maxSameOpponentMatchups: MaximumSameOpponentMatchups - ) -> Set { - var pairs = Set(minimumCapacity: (entries.count-1) * 2) - let sortedEntries = entries.sorted() - - var index = 0 - while index < sortedEntries.count - 1 { - let home = sortedEntries[index] - index += 1 - let assignedHome = assignedEntryHomeAways[unchecked: home] - let maxSameOpponentMatchups = maxSameOpponentMatchups[unchecked: home] - for away in sortedEntries[index...] { - if assignedHome[unchecked: away].sum < maxSameOpponentMatchups[unchecked: away] { - pairs.insert(.init(team1: home, team2: away)) - } - } - } - return pairs - } -} - // MARK: Get recurring day limit interval extension LeagueScheduleData { static func recurringDayLimitInterval( diff --git a/Sources/league-scheduling/data/LeagueScheduleDataSnapshot.swift b/Sources/league-scheduling/data/LeagueScheduleDataSnapshot.swift index 7455158..1c99ab1 100644 --- a/Sources/league-scheduling/data/LeagueScheduleDataSnapshot.swift +++ b/Sources/league-scheduling/data/LeagueScheduleDataSnapshot.swift @@ -1,7 +1,8 @@ import StaticDateTimes -struct LeagueScheduleDataSnapshot: Sendable { +struct LeagueScheduleDataSnapshot: Sendable { + let rng:Config.RNG let entriesPerMatchup:EntriesPerMatchup let entriesCount:Int let entryDivisions:ContiguousArray @@ -19,44 +20,53 @@ struct LeagueScheduleDataSnapshot: Sendable { var allowedDivisionCombinations:ContiguousArray>> = [] /// - Usage: [`selection index` : `Set`] - var failedMatchupSelections:ContiguousArray> + var failedMatchupSelections:ContiguousArray - var assignmentState:AssignmentStateCopyable + var assignmentState:AssignmentStateCopyable var prioritizeEarlierTimes = false var executionSteps = [ExecutionStep]() var shuffleHistory = [LeagueShuffleAction]() init( + rng: Config.RNG, maxStartingTimes: TimeIndex, startingTimes: [StaticTime], maxLocations: LocationIndex, entriesPerMatchup: EntriesPerMatchup, maximumPlayableMatchups: [UInt32], entries: [Entry.Runtime], - divisionEntries: ContiguousArray>, + divisionEntries: ContiguousArray, matchupDuration: MatchupDuration, gameGap: (Int, Int), sameLocationIfB2B: Bool, locationTravelDurations: [[MatchupDuration]], maxSameOpponentMatchups: MaximumSameOpponentMatchups ) { + self.rng = rng self.entriesPerMatchup = entriesPerMatchup self.entriesCount = entries.count self.gameGap = gameGap self.sameLocationIfB2B = sameLocationIfB2B - var prioritizedEntries = Set(minimumCapacity: entriesCount) + var prioritizedEntries = Config.EntryIDSet() + prioritizedEntries.reserveCapacity(entriesCount) var entryDivisions = ContiguousArray(repeating: 0, count: entriesCount) for (index, entries) in divisionEntries.enumerated() { prioritizedEntries.formUnion(entries) - for entry in entries { + entries.forEach { entry in entryDivisions[unchecked: entry] = Division.IDValue(index) } } self.entryDivisions = entryDivisions - failedMatchupSelections = .init(repeating: Set(), count: entriesCount) + failedMatchupSelections = .init(repeating: .init(), count: entriesCount) + let playsAt = ContiguousArray( + repeating: .init(minimumCapacity: Int(defaultMaxEntryMatchupsPerGameDay)), count: entriesCount + ) + let playsAtTimes = PlaysAtTimesArray( + times: .init(repeating: .init(minimumCapacity: Int(defaultMaxEntryMatchupsPerGameDay)), count: entriesCount) + ) assignmentState = .init( entries: entries, startingTimes: startingTimes, @@ -74,20 +84,21 @@ struct LeagueScheduleDataSnapshot: Sendable { homeMatchups: .init(repeating: 0, count: entriesCount), awayMatchups: .init(repeating: 0, count: entriesCount), maxSameOpponentMatchups: maxSameOpponentMatchups, - allMatchups: [], + allMatchups: .init(), allDivisionMatchups: [], - availableMatchups: [], + availableMatchups: .init(), prioritizedEntries: prioritizedEntries, - availableSlots: [], - playsAt: .init(repeating: Set(minimumCapacity: defaultMaxEntryMatchupsPerGameDay), count: entriesCount), - playsAtTimes: .init(repeating: Set(minimumCapacity: defaultMaxEntryMatchupsPerGameDay), count: entriesCount), + availableSlots: .init(), + playsAt: playsAt, + playsAtTimes: playsAtTimes, playsAtLocations: .init(repeating: Set(minimumCapacity: defaultMaxEntryMatchupsPerGameDay), count: entriesCount), - matchups: [], + matchups: .init(), shuffleHistory: [] ) } - init(_ snapshot: borrowing LeagueScheduleData) { + init(_ snapshot: borrowing LeagueScheduleData) { + rng = snapshot.rng entriesPerMatchup = snapshot.entriesPerMatchup entriesCount = snapshot.entriesCount entryDivisions = snapshot.entryDivisions diff --git a/Sources/league-scheduling/data/MatchupBlock.swift b/Sources/league-scheduling/data/MatchupBlock.swift index d29c3fb..986d6e5 100644 --- a/Sources/league-scheduling/data/MatchupBlock.swift +++ b/Sources/league-scheduling/data/MatchupBlock.swift @@ -6,7 +6,7 @@ extension LeagueScheduleData { amount: Int, division: Division.IDValue, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable - ) -> Set? { + ) -> Config.MatchupSet? { if gameGap.min == 1 && gameGap.max == 1 { return Self.assignBlockOfMatchups( amount: amount, @@ -18,6 +18,7 @@ extension LeagueScheduleData { gameGap: gameGap, entryMatchupsPerGameDay: defaultMaxEntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: divisionRecurringDayLimitInterval, + rng: &rng, assignmentState: &assignmentState, selectSlot: SelectSlotB2B(entryMatchupsPerGameDay: defaultMaxEntryMatchupsPerGameDay), canPlayAt: canPlayAt @@ -33,6 +34,7 @@ extension LeagueScheduleData { gameGap: gameGap, entryMatchupsPerGameDay: defaultMaxEntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: divisionRecurringDayLimitInterval, + rng: &rng, assignmentState: &assignmentState, selectSlot: SelectSlotNormal(), canPlayAt: canPlayAt @@ -53,15 +55,16 @@ extension LeagueScheduleData { gameGap: GameGap.TupleValue, entryMatchupsPerGameDay: EntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: ContiguousArray, - assignmentState: inout AssignmentState, + rng: inout some RandomNumberGenerator, + assignmentState: inout AssignmentState, selectSlot: borrowing some SelectSlotProtocol & ~Copyable, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable - ) -> Set? { + ) -> Config.MatchupSet? { let limit = amount * entryMatchupsPerGameDay var remainingPrioritizedEntries = assignmentState.prioritizedEntries var remainingAvailableSlots = assignmentState.availableSlots var localAssignmentState = assignmentState.copy() - localAssignmentState.matchups.removeAll(keepingCapacity: true) + localAssignmentState.matchups.removeAllKeepingCapacity() localAssignmentState.recalculateAvailableMatchups( day: day, entryMatchupsPerGameDay: entryMatchupsPerGameDay, @@ -72,8 +75,9 @@ extension LeagueScheduleData { print("assignedEntryHomeAways=\(localAssignmentState.assignedEntryHomeAways.map { $0.map { $0.sum } })") #endif // assign initial matchups - var adjacentTimes = Set() - var selectedEntries = Set(minimumCapacity: amount * entriesPerMatchup) + var adjacentTimes = Config.TimeSet() + var selectedEntries = Config.EntryIDSet() + selectedEntries.reserveCapacity(amount * entriesPerMatchup) // assign the first matchup, prioritizing the matchup's time guard let firstMatchup = selectAndAssignMatchup( @@ -85,6 +89,7 @@ extension LeagueScheduleData { entryMatchupsPerGameDay: entryMatchupsPerGameDay, divisionRecurringDayLimitInterval: divisionRecurringDayLimitInterval, allAvailableMatchups: localAssignmentState.availableMatchups, + rng: &rng, localAssignmentState: &localAssignmentState, shouldSkipSelection: { _ in false }, remainingPrioritizedEntries: &remainingPrioritizedEntries, @@ -92,7 +97,7 @@ extension LeagueScheduleData { selectSlot: selectSlot, canPlayAt: canPlayAt ) else { return nil } - adjacentTimes = Self.adjacentTimes(for: firstMatchup.time, entryMatchupsPerGameDay: entryMatchupsPerGameDay) + adjacentTimes = calculateAdjacentTimes(for: firstMatchup.time, entryMatchupsPerGameDay: entryMatchupsPerGameDay) localAssignmentState.availableSlots = localAssignmentState.availableSlots.filter { $0.time == firstMatchup.time } localAssignmentState.recalculateAllRemainingAllocations( day: day, @@ -112,6 +117,7 @@ extension LeagueScheduleData { entryMatchupsPerGameDay: entryMatchupsPerGameDay, divisionRecurringDayLimitInterval: divisionRecurringDayLimitInterval, allAvailableMatchups: localAssignmentState.availableMatchups, + rng: &rng, localAssignmentState: &localAssignmentState, remainingPrioritizedEntries: &remainingPrioritizedEntries, selectedEntries: &selectedEntries, @@ -126,22 +132,22 @@ extension LeagueScheduleData { // assign the last matchup let lastLocalAssignmentStateAvailableMatchups = localAssignmentState.availableMatchups let lastSelectedEntries = selectedEntries - let shouldSkipSelection:(MatchupPair) -> Bool = entryMatchupsPerGameDay % 2 == 0 ? { + let shouldSkipSelection:(MatchupPair) -> Bool = entryMatchupsPerGameDay % 2 == 0 ? { pair in var targetEntries = lastSelectedEntries - targetEntries.insert($0.team1) - targetEntries.insert($0.team2) + targetEntries.insertMember(pair.team1) + targetEntries.insertMember(pair.team2) let availableMatchups = lastLocalAssignmentStateAvailableMatchups.filter { targetEntries.contains($0.team1) && targetEntries.contains($0.team2) } - for entryID in targetEntries { + return targetEntries.forEachWithReturn { entryID in if availableMatchups.first(where: { $0.team1 == entryID || $0.team2 == entryID }) == nil { #if LOG - print("assignBlockOfMatchups;i == lastMatchupIndex;$0=\($0);targetEntries (\(targetEntries.count))=\(targetEntries);entryID=\(entryID);availableMatchups.first of entryID == nil;skipping $0") + print("assignBlockOfMatchups;i == lastMatchupIndex;pair=\(pair);targetEntries (\(targetEntries.count))=\(targetEntries);entryID=\(entryID);availableMatchups.first of entryID == nil;skipping $0") #endif return true } - } - return false + return nil + } ?? false } : { _ in false } guard let _ = selectAndAssignMatchup( day: day, @@ -152,6 +158,7 @@ extension LeagueScheduleData { entryMatchupsPerGameDay: entryMatchupsPerGameDay, divisionRecurringDayLimitInterval: divisionRecurringDayLimitInterval, allAvailableMatchups: localAssignmentState.availableMatchups, + rng: &rng, localAssignmentState: &localAssignmentState, shouldSkipSelection: shouldSkipSelection, remainingPrioritizedEntries: &remainingPrioritizedEntries, @@ -160,7 +167,7 @@ extension LeagueScheduleData { canPlayAt: canPlayAt ) else { return nil } // last matchup was successfully assigned; continue - if var time = adjacentTimes.randomElement() { // TODO: pick an adjacent time that needs to be prioritized over others + if var time = adjacentTimes.randomElement(using: &rng) { // TODO: pick an adjacent time that needs to be prioritized over others // assign matchups from previously scheduled entries until they have played all their games localAssignmentState.availableMatchups = localAssignmentState.availableMatchups.filter { selectedEntries.contains($0.team1) && selectedEntries.contains($0.team2) @@ -184,16 +191,17 @@ extension LeagueScheduleData { entryMatchupsPerGameDay: entryMatchupsPerGameDay, divisionRecurringDayLimitInterval: divisionRecurringDayLimitInterval, allAvailableMatchups: localAssignmentState.availableMatchups, + rng: &rng, assignmentState: &localAssignmentState, selectSlot: selectSlot, canPlayAt: canPlayAt ) else { return nil } } - adjacentTimes.remove(time) + adjacentTimes.removeMember(time) #if LOG print("assignBlockOfMatchups;j=\(j);finished time \(time)") #endif - if let nextTime = adjacentTimes.randomElement() { + if let nextTime = adjacentTimes.randomElement(using: &rng) { time = nextTime } } @@ -202,8 +210,8 @@ extension LeagueScheduleData { let previousMatchups = assignmentState.matchups assignmentState = localAssignmentState.copy() assignmentState.matchups.formUnion(previousMatchups) - for matchup in localAssignmentState.matchups { - remainingAvailableSlots.remove(matchup.slot) + localAssignmentState.matchups.forEach { matchup in + remainingAvailableSlots.removeMember(matchup.slot) } assignmentState.availableSlots = remainingAvailableSlots assignmentState.prioritizedEntries = remainingPrioritizedEntries @@ -221,10 +229,11 @@ extension LeagueScheduleData { gameGap: GameGap.TupleValue, entryMatchupsPerGameDay: EntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: ContiguousArray, - allAvailableMatchups: Set, - localAssignmentState: inout AssignmentState, - remainingPrioritizedEntries: inout Set, - selectedEntries: inout Set, + allAvailableMatchups: Config.MatchupPairSet, + rng: inout some RandomNumberGenerator, + localAssignmentState: inout AssignmentState, + remainingPrioritizedEntries: inout Config.EntryIDSet, + selectedEntries: inout Config.EntryIDSet, selectSlot: borrowing some SelectSlotProtocol & ~Copyable, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) -> Matchup? { @@ -237,6 +246,7 @@ extension LeagueScheduleData { entryMatchupsPerGameDay: entryMatchupsPerGameDay, divisionRecurringDayLimitInterval: divisionRecurringDayLimitInterval, allAvailableMatchups: allAvailableMatchups, + rng: &rng, assignmentState: &localAssignmentState, selectSlot: selectSlot, canPlayAt: canPlayAt @@ -244,10 +254,10 @@ extension LeagueScheduleData { return nil } // successfully assigned - remainingPrioritizedEntries.remove(leagueMatchup.home) - remainingPrioritizedEntries.remove(leagueMatchup.away) - selectedEntries.insert(leagueMatchup.home) - selectedEntries.insert(leagueMatchup.away) + remainingPrioritizedEntries.removeMember(leagueMatchup.home) + remainingPrioritizedEntries.removeMember(leagueMatchup.away) + selectedEntries.insertMember(leagueMatchup.home) + selectedEntries.insertMember(leagueMatchup.away) return leagueMatchup } private static func selectAndAssignMatchup( @@ -258,11 +268,12 @@ extension LeagueScheduleData { gameGap: GameGap.TupleValue, entryMatchupsPerGameDay: EntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: ContiguousArray, - allAvailableMatchups: Set, - localAssignmentState: inout AssignmentState, + allAvailableMatchups: Config.MatchupPairSet, + rng: inout some RandomNumberGenerator, + localAssignmentState: inout AssignmentState, shouldSkipSelection: (MatchupPair) -> Bool, - remainingPrioritizedEntries: inout Set, - selectedEntries: inout Set, + remainingPrioritizedEntries: inout Config.EntryIDSet, + selectedEntries: inout Config.EntryIDSet, selectSlot: borrowing some SelectSlotProtocol & ~Copyable, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) -> Matchup? { @@ -275,6 +286,7 @@ extension LeagueScheduleData { entryMatchupsPerGameDay: entryMatchupsPerGameDay, divisionRecurringDayLimitInterval: divisionRecurringDayLimitInterval, allAvailableMatchups: allAvailableMatchups, + rng: &rng, assignmentState: &localAssignmentState, shouldSkipSelection: shouldSkipSelection, selectSlot: selectSlot, @@ -283,36 +295,10 @@ extension LeagueScheduleData { return nil } // successfully assigned - remainingPrioritizedEntries.remove(leagueMatchup.home) - remainingPrioritizedEntries.remove(leagueMatchup.away) - selectedEntries.insert(leagueMatchup.home) - selectedEntries.insert(leagueMatchup.away) + remainingPrioritizedEntries.removeMember(leagueMatchup.home) + remainingPrioritizedEntries.removeMember(leagueMatchup.away) + selectedEntries.insertMember(leagueMatchup.home) + selectedEntries.insertMember(leagueMatchup.away) return leagueMatchup } -} - -// MARK: Adjacent times -extension LeagueScheduleData { - static func adjacentTimes( - for time: TimeIndex, - entryMatchupsPerGameDay: EntryMatchupsPerGameDay - ) -> Set { - var adjacentTimes = Set() - let timeIndex = time % entryMatchupsPerGameDay - if timeIndex == 0 { - for i in 1.. +struct PrioritizedMatchups: Sendable, ~Copyable { + private(set) var matchups:Config.MatchupPairSet private(set) var availableMatchupCountForEntry:ContiguousArray init( entriesCount: Int, - prioritizedEntries: Set, - availableMatchups: Set + prioritizedEntries: Config.EntryIDSet, + availableMatchups: Config.MatchupPairSet ) { let matchups = Self.filterMatchups(prioritizedEntries: prioritizedEntries, availableMatchups: availableMatchups) var availableMatchupCountForEntry = ContiguousArray(repeating: 0, count: entriesCount) - for matchup in matchups { + matchups.forEach { matchup in availableMatchupCountForEntry[unchecked: matchup.team1] += 1 availableMatchupCountForEntry[unchecked: matchup.team2] += 1 } @@ -19,14 +19,14 @@ struct PrioritizedMatchups: Sendable, ~Copyable { } mutating func update( - prioritizedEntries: Set, - availableMatchups: Set + prioritizedEntries: Config.EntryIDSet, + availableMatchups: Config.MatchupPairSet ) { matchups = Self.filterMatchups(prioritizedEntries: prioritizedEntries, availableMatchups: availableMatchups) for i in availableMatchupCountForEntry.indices { availableMatchupCountForEntry[unchecked: i] = 0 } - for matchup in matchups { + matchups.forEach { matchup in availableMatchupCountForEntry[unchecked: matchup.team1] += 1 availableMatchupCountForEntry[unchecked: matchup.team2] += 1 } @@ -34,13 +34,13 @@ struct PrioritizedMatchups: Sendable, ~Copyable { /// Removes the specified matchup pair from `matchups`. mutating func remove(_ matchup: MatchupPair) { - matchups.remove(matchup) + matchups.removeMember(matchup) } private static func filterMatchups( - prioritizedEntries: Set, - availableMatchups: Set - ) -> Set { + prioritizedEntries: Config.EntryIDSet, + availableMatchups: Config.MatchupPairSet + ) -> Config.MatchupPairSet { if prioritizedEntries.isEmpty { return availableMatchups } diff --git a/Sources/league-scheduling/data/Redistribute.swift b/Sources/league-scheduling/data/Redistribute.swift index cf1c3ad..073fcf3 100644 --- a/Sources/league-scheduling/data/Redistribute.swift +++ b/Sources/league-scheduling/data/Redistribute.swift @@ -48,7 +48,8 @@ extension LeagueScheduleData { dayIndex: day, startDayIndex: startDayIndex, settings: settings, - data: self + entryMatchupsPerGameDay: defaultMaxEntryMatchupsPerGameDay, + entriesPerMatchup: entriesPerMatchup ) } let previousSchedule = generationData.schedule diff --git a/Sources/league-scheduling/data/RedistributionData.swift b/Sources/league-scheduling/data/RedistributionData.swift index bd9407f..9bb3e3a 100644 --- a/Sources/league-scheduling/data/RedistributionData.swift +++ b/Sources/league-scheduling/data/RedistributionData.swift @@ -1,5 +1,5 @@ -struct RedistributionData: Sendable { +struct RedistributionData: Sendable { /// The latest `DayIndex` that is allowed to redistribute matchups from. let startDayIndex:DayIndex let entryMatchupsPerGameDay:EntryMatchupsPerGameDay @@ -14,14 +14,15 @@ struct RedistributionData: Sendable { dayIndex: DayIndex, startDayIndex: DayIndex, settings: RequestPayload.Runtime, - data: borrowing LeagueScheduleData + entryMatchupsPerGameDay: EntryMatchupsPerGameDay, + entriesPerMatchup: EntriesPerMatchup ) { self.startDayIndex = startDayIndex - self.entryMatchupsPerGameDay = data.defaultMaxEntryMatchupsPerGameDay - redistributedEntries = .init(repeating: 0, count: data.entriesCount) + self.entryMatchupsPerGameDay = entryMatchupsPerGameDay + redistributedEntries = .init(repeating: 0, count: settings.entries.count) redistributed = [] - let threshold = (data.entriesCount / data.entriesPerMatchup)// * entryMatchupsPerGameDay + let threshold = (settings.entries.count / entriesPerMatchup)// * entryMatchupsPerGameDay var minMatchupsRequired = threshold var maxMovableMatchups = threshold if let r = settings.daySettings[unchecked: dayIndex].general.redistributionSettings ?? settings.general.redistributionSettings { @@ -42,7 +43,7 @@ extension RedistributionData { canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable, day: DayIndex, gameGap: GameGap.TupleValue, - assignmentState: inout AssignmentState, + assignmentState: inout AssignmentState, executionSteps: inout [ExecutionStep], generationData: inout LeagueGenerationData ) -> Bool { @@ -52,7 +53,7 @@ extension RedistributionData { #endif var assigned = 0 - var redistributables = Set() + var redistributables = Config.RedistributableMatchupSet() for fromDayIndex in stride(from: startDayIndex, through: 0, by: -1) { for matchup in generationData.schedule[unchecked: fromDayIndex] { guard !redistributed.contains(matchup) else { continue } @@ -67,7 +68,7 @@ extension RedistributionData { let homeMaxAssignedLocations = assignmentState.maxLocationAllocations[unchecked: matchup.home] let awayMaxAssignedLocations = assignmentState.maxLocationAllocations[unchecked: matchup.away] - for slot in assignmentState.availableSlots { + assignmentState.availableSlots.forEach { slot in assignmentState.decrementAssignData(home: matchup.home, away: matchup.away, slot: matchup.slot) if canPlayAt.test( time: slot.time, @@ -96,7 +97,7 @@ extension RedistributionData { maxLocationNumber: UInt8(awayMaxAssignedLocations[unchecked: slot.location]), gameGap: gameGap ) { - redistributables.insert(.init(fromDay: fromDayIndex, matchup: matchup, toSlot: slot)) + redistributables.insertMember(.init(fromDay: fromDayIndex, matchup: matchup, toSlot: slot)) } assignmentState.incrementAssignData(home: matchup.home, away: matchup.away, slot: matchup.slot) } @@ -127,17 +128,17 @@ extension RedistributionData { // MARK: Select redistributable extension RedistributionData { private func selectRedistributable( - from redistributables: Set, + from redistributables: Config.RedistributableMatchupSet, generationData: LeagueGenerationData - ) -> Redistributable? { - var redistributable:Redistributable? = nil + ) -> RedistributableMatchup? { + var redistributable:RedistributableMatchup? = nil // prioritize entries that have been redistributed the least var (cMin, cMax):(UInt16, UInt16) = (.max, .max) - for r in redistributables { + redistributables.forEach { r in if generationData.schedule[unchecked: r.fromDay].count <= minMatchupsRequired { // don't take from the day since the matchups for it will render the day incomplete - continue + return } let (rMin, rMax) = calculateMinMax(matchup: r.matchup) if rMin < cMin { @@ -171,9 +172,9 @@ extension RedistributionData { // MARK: Redistribute extension RedistributionData { private mutating func redistribute( - redistributable: inout Redistributable, - redistributables: inout Set, - assignmentState: inout AssignmentState, + redistributable: inout RedistributableMatchup, + redistributables: inout Config.RedistributableMatchupSet, + assignmentState: inout AssignmentState, generationData: inout LeagueGenerationData ) { generationData.schedule[unchecked: redistributable.fromDay].remove(redistributable.matchup) @@ -192,18 +193,9 @@ extension RedistributionData { redistributable.matchup.time = redistributable.toSlot.time redistributable.matchup.location = redistributable.toSlot.location - assignmentState.matchups.insert(redistributable.matchup) - assignmentState.availableSlots.remove(redistributable.toSlot) + assignmentState.matchups.insertMember(redistributable.matchup) + assignmentState.availableSlots.removeMember(redistributable.toSlot) assignmentState.incrementAssignData(home: redistributable.matchup.home, away: redistributable.matchup.away, slot: redistributable.toSlot) assignmentState.insertPlaysAt(home: redistributable.matchup.home, away: redistributable.matchup.away, slot: redistributable.toSlot) } -} - -// MARK: Redistributable -extension RedistributionData { - private struct Redistributable: Hashable, Sendable { - let fromDay:DayIndex - var matchup:Matchup - let toSlot:AvailableSlot - } } \ No newline at end of file diff --git a/Sources/league-scheduling/data/RemainingAllocations.swift b/Sources/league-scheduling/data/RemainingAllocations.swift index 96cc13d..db6f282 100644 --- a/Sources/league-scheduling/data/RemainingAllocations.swift +++ b/Sources/league-scheduling/data/RemainingAllocations.swift @@ -6,7 +6,7 @@ extension AssignmentState { ) { remainingAllocations = .init(repeating: availableSlots, count: entriesCount) var cached = Set(minimumCapacity: entriesCount) - for matchup in availableMatchups { + availableMatchups.forEach { matchup in recalculateNewDayRemainingAllocations( for: matchup, cached: &cached @@ -41,9 +41,9 @@ extension AssignmentState { let maxTimeNumbers = maxTimeAllocations[unchecked: team] let maxLocationNumbers = maxLocationAllocations[unchecked: team] var available = availableSlots - for slot in availableSlots { + availableSlots.forEach { slot in if timeNumbers[unchecked: slot.time] >= maxTimeNumbers[unchecked: slot.time] || locationNumbers[unchecked: slot.location] >= maxLocationNumbers[unchecked: slot.location] { - available.remove(slot) + available.removeMember(slot) } } remainingAllocations[unchecked: team] = available @@ -59,7 +59,7 @@ extension AssignmentState { canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) { var cached = Set(minimumCapacity: entriesCount) - for matchup in availableMatchups { + availableMatchups.forEach { matchup in recalculateRemainingAllocations( day: day, for: matchup, @@ -115,7 +115,7 @@ extension AssignmentState { let maxTimeNumbers = maxTimeAllocations[unchecked: team] let maxLocationNumbers = maxLocationAllocations[unchecked: team] var available = availableSlots - for slot in availableSlots { + availableSlots.forEach { slot in if !canPlayAt.test( time: slot.time, location: slot.location, @@ -130,7 +130,7 @@ extension AssignmentState { maxLocationNumber: UInt8(maxLocationNumbers[unchecked: slot.location]), gameGap: gameGap ) { - available.remove(slot) + available.removeMember(slot) } } remainingAllocations[unchecked: team] = available diff --git a/Sources/league-scheduling/data/SelectMatchup.swift b/Sources/league-scheduling/data/SelectMatchup.swift index 57cc64e..cc26571 100644 --- a/Sources/league-scheduling/data/SelectMatchup.swift +++ b/Sources/league-scheduling/data/SelectMatchup.swift @@ -2,163 +2,168 @@ // MARK: Select matchup extension LeagueScheduleData { /// - Returns: Matchup pair that should be prioritized to be scheduled due to how many allocations it has remaining. - func selectMatchup(prioritizedMatchups: borrowing PrioritizedMatchups) -> MatchupPair? { - return assignmentState.selectMatchup(prioritizedMatchups: prioritizedMatchups) + mutating func selectMatchup(prioritizedMatchups: borrowing PrioritizedMatchups) -> MatchupPair? { + return assignmentState.selectMatchup( + prioritizedMatchups: prioritizedMatchups, + rng: &rng + ) } } extension AssignmentState { /// - Returns: Matchup pair that should be prioritized to be scheduled due to how many allocations it has remaining. func selectMatchup( - prioritizedMatchups: borrowing PrioritizedMatchups + prioritizedMatchups: borrowing PrioritizedMatchups, + rng: inout some RandomNumberGenerator ) -> MatchupPair? { return Self.selectMatchup( prioritizedMatchups: prioritizedMatchups, numberOfAssignedMatchups: numberOfAssignedMatchups, recurringDayLimits: recurringDayLimits, - remainingAllocations: remainingAllocations + remainingAllocations: remainingAllocations, + rng: &rng ) } /// - Returns: Matchup pair that should be prioritized to be scheduled due to how many allocations it has remaining. static func selectMatchup( - prioritizedMatchups: borrowing PrioritizedMatchups, + prioritizedMatchups: borrowing PrioritizedMatchups, numberOfAssignedMatchups: [Int], recurringDayLimits: RecurringDayLimits, - remainingAllocations: RemainingAllocations + remainingAllocations: Config.RemainingAllocations, + rng: inout some RandomNumberGenerator ) -> MatchupPair? { #if LOG print("SelectMatchup;selectMatchup;prioritizedMatchups.count=\(prioritizedMatchups.matchups.count);availableMatchupCountForEntry=\(prioritizedMatchups.availableMatchupCountForEntry)") #endif - guard let first = prioritizedMatchups.matchups.first else { return nil } - guard prioritizedMatchups.matchups.count > 1 else { - return first//recurringDayLimit(for: first) <= day ? first : nil - } - let firstNumberOfMatchupsPlayedSoFar = numberOfMatchupsPlayedSoFar(for: first, numberOfAssignedMatchups: numberOfAssignedMatchups) - var selected = SelectedMatchup( - pair: first, - minMatchupsPlayedSoFar: firstNumberOfMatchupsPlayedSoFar.minimum, - totalMatchupsPlayedSoFar: firstNumberOfMatchupsPlayedSoFar.total, - remainingAllocations: Self.remainingAllocations(for: first, remainingAllocations: remainingAllocations), - remainingMatchupCount: remainingMatchupCount(for: first, prioritizedMatchups.availableMatchupCountForEntry), - recurringDayLimit: recurringDayLimit(for: first, recurringDayLimits: recurringDayLimits) - ) + var selected:SelectedMatchup! = nil // introduce a pool of matchup pairs of equal priority, and random selection, so that we don't repeat identical assignments when // - regenerating a failed day // - selecting the last matchup pair out of previous pairs of equal priority - var pool = Set() - for pair in prioritizedMatchups.matchups[prioritizedMatchups.matchups.index(after: prioritizedMatchups.matchups.startIndex)...] { + var pool = Config.MatchupPairSet() + prioritizedMatchups.matchups.forEach { pair in let (pairMinMatchupsPlayedSoFar, pairTotalMatchupsPlayedSoFar) = numberOfMatchupsPlayedSoFar(for: pair, numberOfAssignedMatchups: numberOfAssignedMatchups) - guard pairMinMatchupsPlayedSoFar == selected.minMatchupsPlayedSoFar else { - if pairMinMatchupsPlayedSoFar < selected.minMatchupsPlayedSoFar { - selected = select( - pair: pair, - minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, - totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, - recurringDayLimit: recurringDayLimit(for: pair, recurringDayLimits: recurringDayLimits), - remainingAllocations: Self.remainingAllocations(for: pair, remainingAllocations: remainingAllocations), - remainingMatchupCount: remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), - pool: &pool - ) + if selected == nil { + selected = select( + pair: pair, + minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, + totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, + recurringDayLimit: recurringDayLimit(for: pair, recurringDayLimits: recurringDayLimits), + remainingAllocations: Self.remainingAllocations(for: pair, remainingAllocations: remainingAllocations), + remainingMatchupCount: remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), + pool: &pool + ) + } else { + guard pairMinMatchupsPlayedSoFar == selected.minMatchupsPlayedSoFar else { + if pairMinMatchupsPlayedSoFar < selected.minMatchupsPlayedSoFar { + selected = select( + pair: pair, + minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, + totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, + recurringDayLimit: recurringDayLimit(for: pair, recurringDayLimits: recurringDayLimits), + remainingAllocations: Self.remainingAllocations(for: pair, remainingAllocations: remainingAllocations), + remainingMatchupCount: remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), + pool: &pool + ) + } + return } - continue - } - guard pairTotalMatchupsPlayedSoFar == selected.totalMatchupsPlayedSoFar else { - if pairTotalMatchupsPlayedSoFar < selected.totalMatchupsPlayedSoFar { - selected = select( - pair: pair, - minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, - totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, - recurringDayLimit: recurringDayLimit(for: pair, recurringDayLimits: recurringDayLimits), - remainingAllocations: Self.remainingAllocations(for: pair, remainingAllocations: remainingAllocations), - remainingMatchupCount: remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), - pool: &pool - ) + guard pairTotalMatchupsPlayedSoFar == selected.totalMatchupsPlayedSoFar else { + if pairTotalMatchupsPlayedSoFar < selected.totalMatchupsPlayedSoFar { + selected = select( + pair: pair, + minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, + totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, + recurringDayLimit: recurringDayLimit(for: pair, recurringDayLimits: recurringDayLimits), + remainingAllocations: Self.remainingAllocations(for: pair, remainingAllocations: remainingAllocations), + remainingMatchupCount: remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), + pool: &pool + ) + } + return } - continue - } - let pairRecurringDayLimit = recurringDayLimit(for: pair, recurringDayLimits: recurringDayLimits) - guard pairRecurringDayLimit == selected.recurringDayLimit else { - if pairRecurringDayLimit < selected.recurringDayLimit { - selected = select( - pair: pair, - minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, - totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, - recurringDayLimit: pairRecurringDayLimit, - remainingAllocations: Self.remainingAllocations(for: pair, remainingAllocations: remainingAllocations), - remainingMatchupCount: remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), - pool: &pool - ) + let pairRecurringDayLimit = recurringDayLimit(for: pair, recurringDayLimits: recurringDayLimits) + guard pairRecurringDayLimit == selected.recurringDayLimit else { + if pairRecurringDayLimit < selected.recurringDayLimit { + selected = select( + pair: pair, + minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, + totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, + recurringDayLimit: pairRecurringDayLimit, + remainingAllocations: Self.remainingAllocations(for: pair, remainingAllocations: remainingAllocations), + remainingMatchupCount: remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), + pool: &pool + ) + } + return } - continue - } - let pairRemainingAllocations = Self.remainingAllocations(for: pair, remainingAllocations: remainingAllocations) - guard pairRemainingAllocations.min == selected.remainingAllocations.min else { - if pairRemainingAllocations.min < selected.remainingAllocations.min { - selected = select( - pair: pair, - minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, - totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, - recurringDayLimit: pairRecurringDayLimit, - remainingAllocations: pairRemainingAllocations, - remainingMatchupCount: Self.remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), - pool: &pool - ) + let pairRemainingAllocations = Self.remainingAllocations(for: pair, remainingAllocations: remainingAllocations) + guard pairRemainingAllocations.min == selected.remainingAllocations.min else { + if pairRemainingAllocations.min < selected.remainingAllocations.min { + selected = select( + pair: pair, + minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, + totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, + recurringDayLimit: pairRecurringDayLimit, + remainingAllocations: pairRemainingAllocations, + remainingMatchupCount: Self.remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), + pool: &pool + ) + } + return } - continue - } - guard pairRemainingAllocations.max == selected.remainingAllocations.max else { - if pairRemainingAllocations.max < selected.remainingAllocations.max { - selected = select( - pair: pair, - minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, - totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, - recurringDayLimit: pairRecurringDayLimit, - remainingAllocations: pairRemainingAllocations, - remainingMatchupCount: Self.remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), - pool: &pool - ) + guard pairRemainingAllocations.max == selected.remainingAllocations.max else { + if pairRemainingAllocations.max < selected.remainingAllocations.max { + selected = select( + pair: pair, + minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, + totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, + recurringDayLimit: pairRecurringDayLimit, + remainingAllocations: pairRemainingAllocations, + remainingMatchupCount: Self.remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry), + pool: &pool + ) + } + return } - continue - } - let pairRemainingMatchupCount = Self.remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry) - guard pairRemainingMatchupCount.min == selected.remainingMatchupCount.min else { - if pairRemainingMatchupCount.min < selected.remainingMatchupCount.min { - selected = select( - pair: pair, - minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, - totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, - recurringDayLimit: pairRecurringDayLimit, - remainingAllocations: pairRemainingAllocations, - remainingMatchupCount: pairRemainingMatchupCount, - pool: &pool - ) + let pairRemainingMatchupCount = Self.remainingMatchupCount(for: pair, prioritizedMatchups.availableMatchupCountForEntry) + guard pairRemainingMatchupCount.min == selected.remainingMatchupCount.min else { + if pairRemainingMatchupCount.min < selected.remainingMatchupCount.min { + selected = select( + pair: pair, + minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, + totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, + recurringDayLimit: pairRecurringDayLimit, + remainingAllocations: pairRemainingAllocations, + remainingMatchupCount: pairRemainingMatchupCount, + pool: &pool + ) + } + return } - continue - } - guard pairRemainingMatchupCount.max == selected.remainingMatchupCount.max else { - if pairRemainingMatchupCount.max < selected.remainingMatchupCount.max { - selected = select( - pair: pair, - minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, - totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, - recurringDayLimit: pairRecurringDayLimit, - remainingAllocations: pairRemainingAllocations, - remainingMatchupCount: pairRemainingMatchupCount, - pool: &pool - ) + guard pairRemainingMatchupCount.max == selected.remainingMatchupCount.max else { + if pairRemainingMatchupCount.max < selected.remainingMatchupCount.max { + selected = select( + pair: pair, + minMatchupsPlayedSoFar: pairMinMatchupsPlayedSoFar, + totalMatchupsPlayedSoFar: pairTotalMatchupsPlayedSoFar, + recurringDayLimit: pairRecurringDayLimit, + remainingAllocations: pairRemainingAllocations, + remainingMatchupCount: pairRemainingMatchupCount, + pool: &pool + ) + } + return } - continue + pool.insertMember(pair) } - - pool.insert(pair) } #if LOG - print("SelectMatchup;selectMatchup;selected.pair=\(selected.pair.description);pool=\(pool.map({ $0.description }))") + print("SelectMatchup;selectMatchup;selected.pair=\(selected?.pair.description);pool=\(pool.map({ $0.description }))") #endif - return pool.isEmpty ? selected.pair : pool.randomElement() + return pool.isEmpty ? selected?.pair : pool.randomElement(using: &rng) } } @@ -181,7 +186,7 @@ extension AssignmentState { private static func recurringDayLimit(for pair: MatchupPair, recurringDayLimits: RecurringDayLimits) -> RecurringDayLimitInterval { return recurringDayLimits[unchecked: pair.team1][unchecked: pair.team2] } - private static func remainingAllocations(for pair: MatchupPair, remainingAllocations: RemainingAllocations) -> (min: Int, max: Int) { + private static func remainingAllocations(for pair: MatchupPair, remainingAllocations: Config.RemainingAllocations) -> (min: Int, max: Int) { let team1 = remainingAllocations[unchecked: pair.team1].count let team2 = remainingAllocations[unchecked: pair.team2].count return ( @@ -204,10 +209,10 @@ extension AssignmentState { recurringDayLimit: RecurringDayLimitInterval, remainingAllocations: (min: Int, max: Int), remainingMatchupCount: (min: Int, max: Int), - pool: inout Set + pool: inout Config.MatchupPairSet ) -> SelectedMatchup { - pool.removeAll(keepingCapacity: true) - pool.insert(pair) + pool.removeAllKeepingCapacity() + pool.insertMember(pair) return .init( pair: pair, minMatchupsPlayedSoFar: minMatchupsPlayedSoFar, diff --git a/Sources/league-scheduling/data/Shuffle.swift b/Sources/league-scheduling/data/Shuffle.swift index 79d0d72..2d63864 100644 --- a/Sources/league-scheduling/data/Shuffle.swift +++ b/Sources/league-scheduling/data/Shuffle.swift @@ -10,7 +10,7 @@ extension AssignmentState { gameGap: GameGap.TupleValue, entryMatchupsPerGameDay: EntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: ContiguousArray, - allAvailableMatchups: Set, + allAvailableMatchups: Config.MatchupPairSet, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) -> AvailableSlot? { // TODO: fix (can get stuck shuffling the same matchup to the same slot) @@ -32,7 +32,7 @@ extension AssignmentState { let team2LocationNumbers = assignedLocations[unchecked: matchup.team2] let team2MaxTimeNumbers = maxTimeAllocations[unchecked: matchup.team2] let team2MaxLocationNumbers = maxLocationAllocations[unchecked: matchup.team2] - for swapped in matchups { + return matchups.forEachWithReturn { swapped in // make sure the failed assigned matchup is allowed to go where the assigned matchup is guard canPlayAt.test( time: swapped.time, @@ -61,14 +61,14 @@ extension AssignmentState { maxLocationNumber: UInt8(team2MaxLocationNumbers[unchecked: swapped.location]), gameGap: gameGap ) else { - continue + return nil } let swappedSlot = swapped.slot var homePlaysAt = playsAt[unchecked: swapped.home] var awayPlaysAt = playsAt[unchecked: swapped.away] - homePlaysAt.remove(swappedSlot) - awayPlaysAt.remove(swappedSlot) + homePlaysAt.removeMember(swappedSlot) + awayPlaysAt.removeMember(swappedSlot) let homeAllowedTimes = entries[unchecked: swapped.home].gameTimes[unchecked: day] let awayAllowedTimes = entries[unchecked: swapped.away].gameTimes[unchecked: day] @@ -78,8 +78,8 @@ extension AssignmentState { var homePlaysAtTimes = playsAtTimes[unchecked: swapped.home] var awayPlaysAtTimes = playsAtTimes[unchecked: swapped.away] - homePlaysAtTimes.remove(swapped.time) - awayPlaysAtTimes.remove(swapped.time) + homePlaysAtTimes.removeMember(swapped.time) + awayPlaysAtTimes.removeMember(swapped.time) var homePlaysAtLocations = playsAtLocations[unchecked: swapped.home] var awayPlaysAtLocations = playsAtLocations[unchecked: swapped.away] @@ -124,7 +124,7 @@ extension AssignmentState { maxLocationNumber: UInt8(maxAwayLocationNumbers[unchecked: $0.location]), gameGap: gameGap ) - }) else { continue } + }) else { return nil } #if LOG print("shuffle;day=\(day);moved \(swapped) to \(slot) to make room for \(matchup)") @@ -144,6 +144,5 @@ extension AssignmentState { shuffleHistory.append(.init(day: day, from: swappedSlot, to: slot, pair: swapped.pair)) return swappedSlot } - return nil } } \ No newline at end of file diff --git a/Sources/league-scheduling/data/assignment/Assign.swift b/Sources/league-scheduling/data/assignment/Assign.swift index 9c43d01..8c0ef53 100644 --- a/Sources/league-scheduling/data/assignment/Assign.swift +++ b/Sources/league-scheduling/data/assignment/Assign.swift @@ -41,26 +41,26 @@ extension AssignmentState { divisionRecurringDayLimitInterval: ContiguousArray, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) -> Matchup { - prioritizedEntries.remove(matchup.team1) - prioritizedEntries.remove(matchup.team2) + prioritizedEntries.removeMember(matchup.team1) + prioritizedEntries.removeMember(matchup.team2) let home:Entry.IDValue = matchup.team1 let away:Entry.IDValue = matchup.team2 incrementRecurringDayLimits(home: home, away: away, entryDivisions: entryDivisions, divisionRecurringDayLimitInterval: divisionRecurringDayLimitInterval) incrementAssignData(home: home, away: away, slot: slot) insertPlaysAt(home: home, away: away, slot: slot) - availableSlots.remove(slot) + availableSlots.removeMember(slot) let leagueMatchup = Matchup( time: slot.time, location: slot.location, home: home, away: away ) - matchups.insert(leagueMatchup) + matchups.insertMember(leagueMatchup) - availableMatchups.remove(matchup) + availableMatchups.removeMember(matchup) // TODO: fix (why is the following line necessary | it fixes an issue that allowed matchups to exceed the maximumSameOpponentsMatchupsCap, but availableMatchups still contains matchups that shouldn't be scheduled when scheduling b2b) - availableMatchups.remove(.init(team1: matchup.team2, team2: matchup.team1)) + availableMatchups.removeMember(.init(team1: matchup.team2, team2: matchup.team1)) if playsAtTimes[unchecked: home].count == entryMatchupsPerGameDay { #if LOG remainingAllocations[unchecked: home].removeAll() @@ -81,7 +81,7 @@ extension AssignmentState { } #if LOG - for av in availableMatchups { + availableMatchups.forEach { av in if assignedEntryHomeAways[unchecked: av.team1][unchecked: av.team2].sum == maxSameOpponentMatchups[unchecked: av.team1][unchecked: av.team2] { fatalError("assign;day=\(day);gameGap=\(gameGap);matchup=\(matchup);av=\(av);availableSlots.count=\(availableSlots.count);matchups.count=\(matchups.count)") } else if assignedEntryHomeAways[unchecked: av.team2][unchecked: av.team1].sum == maxSameOpponentMatchups[unchecked: av.team2][unchecked: av.team1] { @@ -133,10 +133,10 @@ extension AssignmentState { away: Entry.IDValue, slot: AvailableSlot ) { - playsAt[unchecked: home].insert(slot) - playsAt[unchecked: away].insert(slot) - playsAtTimes[unchecked: home].insert(slot.time) - playsAtTimes[unchecked: away].insert(slot.time) + playsAt[unchecked: home].insertMember(slot) + playsAt[unchecked: away].insertMember(slot) + playsAtTimes.insertMember(entryID: home, member: slot.time) + playsAtTimes.insertMember(entryID: away, member: slot.time) playsAtLocations[unchecked: home].insert(slot.location) playsAtLocations[unchecked: away].insert(slot.location) } diff --git a/Sources/league-scheduling/data/assignment/Move.swift b/Sources/league-scheduling/data/assignment/Move.swift index 79dcc7a..8f62ebe 100644 --- a/Sources/league-scheduling/data/assignment/Move.swift +++ b/Sources/league-scheduling/data/assignment/Move.swift @@ -1,5 +1,4 @@ - // MARK: LeagueScheduleData extension LeagueScheduleData { /// Moves the specified matchup to the given slot on the same day. @@ -7,7 +6,7 @@ extension LeagueScheduleData { matchup: Matchup, to slot: AvailableSlot, day: DayIndex, - allAvailableMatchups: Set, + allAvailableMatchups: Config.MatchupPairSet, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) { assignmentState.move( @@ -36,7 +35,7 @@ extension AssignmentState { gameGap: GameGap.TupleValue, entryMatchupsPerGameDay: EntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: ContiguousArray, - allAvailableMatchups: Set, + allAvailableMatchups: Config.MatchupPairSet, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) { #if LOG diff --git a/Sources/league-scheduling/data/assignment/Unassign.swift b/Sources/league-scheduling/data/assignment/Unassign.swift index ddce8e4..9fb65e7 100644 --- a/Sources/league-scheduling/data/assignment/Unassign.swift +++ b/Sources/league-scheduling/data/assignment/Unassign.swift @@ -9,7 +9,7 @@ extension AssignmentState { gameGap: GameGap.TupleValue, entryMatchupsPerGameDay: EntryMatchupsPerGameDay, divisionRecurringDayLimitInterval: ContiguousArray, - allAvailableMatchups: Set, + allAvailableMatchups: Config.MatchupPairSet, canPlayAt: borrowing some CanPlayAtProtocol & ~Copyable ) { let recurringDayLimitInterval = divisionRecurringDayLimitInterval[unchecked: entryDivisions[unchecked: matchup.home]] @@ -17,8 +17,8 @@ extension AssignmentState { recurringDayLimits[unchecked: matchup.away][unchecked: matchup.home] -= recurringDayLimitInterval decrementAssignData(home: matchup.home, away: matchup.away, slot: matchup.slot) removePlaysAt(home: matchup.home, away: matchup.away, slot: matchup.slot) - availableSlots.insert(matchup.slot) - matchups.remove(matchup) + availableSlots.insertMember(matchup.slot) + matchups.removeMember(matchup) recalculateAvailableMatchups( day: day, @@ -70,10 +70,10 @@ extension AssignmentState { away: Entry.IDValue, slot: AvailableSlot ) { - playsAt[unchecked: home].remove(slot) - playsAt[unchecked: away].remove(slot) - playsAtTimes[unchecked: home].remove(slot.time) - playsAtTimes[unchecked: away].remove(slot.time) + playsAt[unchecked: home].removeMember(slot) + playsAt[unchecked: away].removeMember(slot) + playsAtTimes.removeMember(entryID: home, member: slot.time) + playsAtTimes.removeMember(entryID: away, member: slot.time) playsAtLocations[unchecked: home].remove(slot.location) playsAtLocations[unchecked: away].remove(slot.location) } @@ -84,7 +84,7 @@ extension AssignmentState { mutating func recalculateAvailableMatchups( day: DayIndex, entryMatchupsPerGameDay: EntryMatchupsPerGameDay, - allAvailableMatchups: Set + allAvailableMatchups: Config.MatchupPairSet ) { availableMatchups = allAvailableMatchups.filter({ guard assignedEntryHomeAways[unchecked: $0.team1][unchecked: $0.team2].sum < maxSameOpponentMatchups[unchecked: $0.team1][unchecked: $0.team2] diff --git a/Sources/league-scheduling/data/AssignmentState.swift b/Sources/league-scheduling/data/assignmentState/AssignmentState.swift similarity index 87% rename from Sources/league-scheduling/data/AssignmentState.swift rename to Sources/league-scheduling/data/assignmentState/AssignmentState.swift index bb044b3..8a0da16 100644 --- a/Sources/league-scheduling/data/AssignmentState.swift +++ b/Sources/league-scheduling/data/assignmentState/AssignmentState.swift @@ -2,7 +2,7 @@ import StaticDateTimes // MARK: Noncopyable -struct AssignmentState: Sendable, ~Copyable { +struct AssignmentState: Sendable, ~Copyable { let entries:[Entry.Runtime] var startingTimes:[StaticTime] var matchupDuration:MatchupDuration @@ -14,7 +14,7 @@ struct AssignmentState: Sendable, ~Copyable { /// Remaining allocations allowed for a matchup pair, for a `DayIndex`. /// /// - Usage: [`Entry.IDValue`: `the number of remaining allocations`] - var remainingAllocations:RemainingAllocations + var remainingAllocations:Config.RemainingAllocations /// When entries can play against each other again. /// @@ -45,31 +45,31 @@ struct AssignmentState: Sendable, ~Copyable { let maxSameOpponentMatchups:MaximumSameOpponentMatchups /// All matchup pairs that can be scheduled. - var allMatchups:Set + var allMatchups:Config.MatchupPairSet /// All matchup pairs that can be scheduled, grouped by division. /// /// - Usage: [`Division.IDValue`: `available matchups`] - var allDivisionMatchups:ContiguousArray> + var allDivisionMatchups:ContiguousArray /// Remaining available matchup pairs that can be assigned for the `day`. - var availableMatchups:Set + var availableMatchups:Config.MatchupPairSet - var prioritizedEntries:Set + var prioritizedEntries:Config.EntryIDSet /// Remaining available slots that can be filled for the `day`. - var availableSlots:Set + var availableSlots:Config.AvailableSlotSet - var playsAt:PlaysAt - var playsAtTimes:PlaysAtTimes + var playsAt:Config.PlaysAt + var playsAtTimes:PlaysAtTimesArray var playsAtLocations:PlaysAtLocations /// Available matchups that can be scheduled. - var matchups:Set + var matchups:Config.MatchupSet var shuffleHistory = [LeagueShuffleAction]() - func copyable() -> AssignmentStateCopyable { + func copyable() -> AssignmentStateCopyable { return .init( entries: entries, startingTimes: startingTimes, @@ -133,7 +133,7 @@ struct AssignmentState: Sendable, ~Copyable { } // MARK: Copyable -struct AssignmentStateCopyable { +struct AssignmentStateCopyable { let entries:[Entry.Runtime] let startingTimes:[StaticTime] let matchupDuration:MatchupDuration @@ -141,7 +141,7 @@ struct AssignmentStateCopyable { /// - Usage: [`Entry.IDValue`: `total number of matchups played so far in the schedule`] var numberOfAssignedMatchups:[Int] - var remainingAllocations:RemainingAllocations + var remainingAllocations:Config.RemainingAllocations var recurringDayLimits:RecurringDayLimits var assignedTimes:AssignedTimes var assignedLocations:AssignedLocations @@ -164,29 +164,29 @@ struct AssignmentStateCopyable { var maxSameOpponentMatchups:MaximumSameOpponentMatchups /// All matchup pairs that can be scheduled - var allMatchups:Set + var allMatchups:Config.MatchupPairSet /// All matchup pairs that can be scheduled, grouped by division. /// /// - Usage: [`Division.IDValue`: `available matchups`] - var allDivisionMatchups:ContiguousArray> + var allDivisionMatchups:ContiguousArray /// Remaining available matchup pairs that can be assigned for the `day`. - var availableMatchups:Set + var availableMatchups:Config.MatchupPairSet - var prioritizedEntries:Set + var prioritizedEntries:Config.EntryIDSet /// Remaining available slots that can be filled for the `day`. - var availableSlots:Set + var availableSlots:Config.AvailableSlotSet - var playsAt:PlaysAt - var playsAtTimes:PlaysAtTimes + var playsAt:Config.PlaysAt + var playsAtTimes:PlaysAtTimesArray var playsAtLocations:PlaysAtLocations - var matchups:Set + var matchups:Config.MatchupSet var shuffleHistory:[LeagueShuffleAction] - func noncopyable() -> AssignmentState { + func noncopyable() -> AssignmentState { return .init( entries: entries, startingTimes: startingTimes, diff --git a/Sources/league-scheduling/data/assignmentState/ScheduleConfiguration.swift b/Sources/league-scheduling/data/assignmentState/ScheduleConfiguration.swift new file mode 100644 index 0000000..a4c0e7b --- /dev/null +++ b/Sources/league-scheduling/data/assignmentState/ScheduleConfiguration.swift @@ -0,0 +1,32 @@ + +protocol ScheduleConfiguration: Sendable, ~Copyable { + associatedtype RNG:RandomNumberGenerator & Sendable + associatedtype TimeSet:SetOfTimeIndexes + associatedtype EntryIDSet:SetOfEntryIDs + associatedtype AvailableSlotSet:SetOfAvailableSlots + associatedtype MatchupPairSet:AbstractSet where MatchupPairSet.Element == MatchupPair + associatedtype MatchupSet:AbstractSet where MatchupSet.Element == Matchup + associatedtype RedistributableMatchupSet:AbstractSet where RedistributableMatchupSet.Element == RedistributableMatchup + associatedtype FlippableMatchupSet:AbstractSet where FlippableMatchupSet.Element == FlippableMatchup + + typealias RemainingAllocations = ContiguousArray + typealias PlaysAt = ContiguousArray + typealias PlaysAtTimes = ContiguousArray +} + +enum ScheduleConfig< + RNG: RandomNumberGenerator & Sendable, + TimeSet: SetOfTimeIndexes, + EntryIDSet: SetOfEntryIDs, + AvailableSlotSet: SetOfAvailableSlots, + MatchupPairSet: AbstractSet, + MatchupSet: AbstractSet, + RedistributableMatchupSet: AbstractSet, + FlippableMatchupSet: AbstractSet + >: ScheduleConfiguration where + MatchupPairSet.Element == MatchupPair, + MatchupSet.Element == Matchup, + RedistributableMatchupSet.Element == RedistributableMatchup, + FlippableMatchupSet.Element == FlippableMatchup + { +} \ No newline at end of file diff --git a/Sources/league-scheduling/data/canPlayAt/CanPlayAt+GameGap.swift b/Sources/league-scheduling/data/canPlayAt/CanPlayAt+GameGap.swift index 780db2c..693e6d0 100644 --- a/Sources/league-scheduling/data/canPlayAt/CanPlayAt+GameGap.swift +++ b/Sources/league-scheduling/data/canPlayAt/CanPlayAt+GameGap.swift @@ -5,11 +5,11 @@ struct CanPlayAtGameGap: Sendable, ~Copyable { /// - Returns: If a team with the provided `playsAtTimes` can play at the given `time` taking into account a `gameGap`. static func test( time: TimeIndex, - playsAtTimes: PlaysAtTimes.Element, + playsAtTimes: borrowing some SetOfTimeIndexes & ~Copyable, gameGap: GameGap.TupleValue ) -> Bool { var closest:TimeIndex? = nil - for playedTime in playsAtTimes { + playsAtTimes.forEach { playedTime in let distance = abs(playedTime.distance(to: time)) if closest == nil || distance < closest! { closest = TimeIndex(distance) diff --git a/Sources/league-scheduling/data/canPlayAt/CanPlayAtNormal.swift b/Sources/league-scheduling/data/canPlayAt/CanPlayAtNormal.swift index dcf1030..1c76aea 100644 --- a/Sources/league-scheduling/data/canPlayAt/CanPlayAtNormal.swift +++ b/Sources/league-scheduling/data/canPlayAt/CanPlayAtNormal.swift @@ -9,8 +9,8 @@ struct CanPlayAtNormal: CanPlayAtProtocol, ~Copyable { location: LocationIndex, allowedTimes: Set, allowedLocations: Set, - playsAt: PlaysAt.Element, - playsAtTimes: PlaysAtTimes.Element, + playsAt: borrowing some SetOfAvailableSlots & ~Copyable, + playsAtTimes: borrowing some SetOfTimeIndexes & ~Copyable, playsAtLocations: PlaysAtLocations.Element, timeNumber: UInt8, locationNumber: UInt8, @@ -39,7 +39,7 @@ struct CanPlayAtNormal: CanPlayAtProtocol, ~Copyable { location: LocationIndex, allowedTimes: Set, allowedLocations: Set, - playsAtTimes: PlaysAtTimes.Element, + playsAtTimes: borrowing some SetOfTimeIndexes & ~Copyable, timeNumber: UInt8, locationNumber: UInt8, maxTimeNumber: UInt8, @@ -66,7 +66,7 @@ struct CanPlayAtNormal: CanPlayAtProtocol, ~Copyable { location: LocationIndex, allowedTimes: Set, allowedLocations: Set, - playsAtTimes: PlaysAtTimes.Element, + playsAtTimes: borrowing some SetOfTimeIndexes & ~Copyable, timeNumber: UInt8, locationNumber: UInt8, maxTimeNumber: UInt8, diff --git a/Sources/league-scheduling/data/canPlayAt/CanPlayAtProtocol.swift b/Sources/league-scheduling/data/canPlayAt/CanPlayAtProtocol.swift index f4e2f91..44fe2cd 100644 --- a/Sources/league-scheduling/data/canPlayAt/CanPlayAtProtocol.swift +++ b/Sources/league-scheduling/data/canPlayAt/CanPlayAtProtocol.swift @@ -8,8 +8,8 @@ protocol CanPlayAtProtocol: Sendable, ~Copyable { location: LocationIndex, allowedTimes: Set, allowedLocations: Set, - playsAt: PlaysAt.Element, - playsAtTimes: PlaysAtTimes.Element, + playsAt: borrowing some SetOfAvailableSlots & ~Copyable, + playsAtTimes: borrowing some SetOfTimeIndexes & ~Copyable, playsAtLocations: PlaysAtLocations.Element, timeNumber: UInt8, locationNumber: UInt8, diff --git a/Sources/league-scheduling/data/canPlayAt/CanPlayAtSameLocationIfB2B.swift b/Sources/league-scheduling/data/canPlayAt/CanPlayAtSameLocationIfB2B.swift index 333e0ba..8b70c2e 100644 --- a/Sources/league-scheduling/data/canPlayAt/CanPlayAtSameLocationIfB2B.swift +++ b/Sources/league-scheduling/data/canPlayAt/CanPlayAtSameLocationIfB2B.swift @@ -7,8 +7,8 @@ struct CanPlayAtSameLocationIfB2B: CanPlayAtProtocol, ~Copyable { location: LocationIndex, allowedTimes: Set, allowedLocations: Set, - playsAt: PlaysAt.Element, - playsAtTimes: PlaysAtTimes.Element, + playsAt: borrowing some SetOfAvailableSlots & ~Copyable, + playsAtTimes: borrowing some SetOfTimeIndexes & ~Copyable, playsAtLocations: PlaysAtLocations.Element, timeNumber: UInt8, locationNumber: UInt8, @@ -40,7 +40,7 @@ struct CanPlayAtSameLocationIfB2B: CanPlayAtProtocol, ~Copyable { static func test( time: TimeIndex, location: LocationIndex, - playsAtTimes: PlaysAtTimes.Element, + playsAtTimes: borrowing some SetOfTimeIndexes & ~Copyable, playsAtLocations: PlaysAtLocations.Element ) -> Bool { if time > 0 && playsAtTimes.contains(time-1) || playsAtTimes.contains(time+1) { diff --git a/Sources/league-scheduling/data/canPlayAt/CanPlayAtWithTravelDurations.swift b/Sources/league-scheduling/data/canPlayAt/CanPlayAtWithTravelDurations.swift index 467d92f..f9af85d 100644 --- a/Sources/league-scheduling/data/canPlayAt/CanPlayAtWithTravelDurations.swift +++ b/Sources/league-scheduling/data/canPlayAt/CanPlayAtWithTravelDurations.swift @@ -11,8 +11,8 @@ struct CanPlayAtWithTravelDurations: CanPlayAtProtocol, ~Copyable { location: LocationIndex, allowedTimes: Set, allowedLocations: Set, - playsAt: PlaysAt.Element, - playsAtTimes: PlaysAtTimes.Element, + playsAt: borrowing some SetOfAvailableSlots & ~Copyable, + playsAtTimes: borrowing some SetOfTimeIndexes & ~Copyable, playsAtLocations: PlaysAtLocations.Element, timeNumber: UInt8, locationNumber: UInt8, @@ -50,12 +50,12 @@ extension CanPlayAtWithTravelDurations { travelDurations: [[MatchupDuration]], time: TimeIndex, location: LocationIndex, - playsAt: PlaysAt.Element, + playsAt: borrowing some SetOfAvailableSlots & ~Copyable, gameGap: GameGap.TupleValue ) -> Bool { var closestSlot:AvailableSlot? = nil var closestDistance:TimeIndex? = nil - for slot in playsAt { + playsAt.forEach { slot in let distance = abs(slot.time.distance(to: time)) if closestSlot == nil || distance < closestSlot!.time { closestSlot = slot diff --git a/Sources/league-scheduling/data/selectSlot/SelectSlotB2B.swift b/Sources/league-scheduling/data/selectSlot/SelectSlotB2B.swift index f4c1f44..8e82ceb 100644 --- a/Sources/league-scheduling/data/selectSlot/SelectSlotB2B.swift +++ b/Sources/league-scheduling/data/selectSlot/SelectSlotB2B.swift @@ -2,14 +2,14 @@ struct SelectSlotB2B: SelectSlotProtocol, ~Copyable { let entryMatchupsPerGameDay:EntryMatchupsPerGameDay - func select( + func select( team1: Entry.IDValue, team2: Entry.IDValue, assignedTimes: AssignedTimes, assignedLocations: AssignedLocations, - playsAtTimes: PlaysAtTimes, + playsAtTimes: borrowing PlaysAtTimesArray, playsAtLocations: PlaysAtLocations, - playableSlots: inout Set + playableSlots: inout some SetOfAvailableSlots & ~Copyable ) -> AvailableSlot? { filter( team1: team1, @@ -29,11 +29,11 @@ struct SelectSlotB2B: SelectSlotProtocol, ~Copyable { extension SelectSlotB2B { /// Mutates `playableSlots`, if `team1` AND `team2` haven't played already, so it only contains the first slots applicable for a matchup block. - private func filter( + private func filter( team1: Entry.IDValue, team2: Entry.IDValue, - playsAtTimes: PlaysAtTimes, - playableSlots: inout Set + playsAtTimes: borrowing PlaysAtTimesArray, + playableSlots: inout some SetOfAvailableSlots & ~Copyable ) { //print("filterSlotBack2Back;playsAtTimes[unchecked: team1].isEmpty=\(playsAtTimes[unchecked: team1].isEmpty);playsAtTimes[unchecked: team2].isEmpty=\(playsAtTimes[unchecked: team2].isEmpty)") if playsAtTimes[unchecked: team1].isEmpty && playsAtTimes[unchecked: team2].isEmpty { diff --git a/Sources/league-scheduling/data/selectSlot/SelectSlotEarliestTime.swift b/Sources/league-scheduling/data/selectSlot/SelectSlotEarliestTime.swift index 6cbb718..20dd36d 100644 --- a/Sources/league-scheduling/data/selectSlot/SelectSlotEarliestTime.swift +++ b/Sources/league-scheduling/data/selectSlot/SelectSlotEarliestTime.swift @@ -1,13 +1,13 @@ struct SelectSlotEarliestTime: SelectSlotProtocol, ~Copyable { - func select( + func select( team1: Entry.IDValue, team2: Entry.IDValue, assignedTimes: AssignedTimes, assignedLocations: AssignedLocations, - playsAtTimes: PlaysAtTimes, + playsAtTimes: borrowing PlaysAtTimesArray, playsAtLocations: PlaysAtLocations, - playableSlots: inout Set + playableSlots: inout some SetOfAvailableSlots & ~Copyable ) -> AvailableSlot? { return Self.select( team1: team1, @@ -25,7 +25,7 @@ extension SelectSlotEarliestTime { team2: Entry.IDValue, assignedTimes: AssignedTimes, assignedLocations: AssignedLocations, - playableSlots: inout Set + playableSlots: inout some SetOfAvailableSlots & ~Copyable ) -> AvailableSlot? { filter(playableSlots: &playableSlots) return SelectSlotNormal.select( @@ -38,9 +38,9 @@ extension SelectSlotEarliestTime { } /// Mutates `playableSlots` so it only contains the slots at the earliest available time. - static func filter(playableSlots: inout Set) { + static func filter(playableSlots: inout some SetOfAvailableSlots & ~Copyable) { var earliestTime = TimeIndex.max - for slot in playableSlots { + playableSlots.forEach { slot in if slot.time < earliestTime { earliestTime = slot.time } diff --git a/Sources/league-scheduling/data/selectSlot/SelectSlotEarliestTimeAndSameLocationIfB2B.swift b/Sources/league-scheduling/data/selectSlot/SelectSlotEarliestTimeAndSameLocationIfB2B.swift index 2b033b4..763ec16 100644 --- a/Sources/league-scheduling/data/selectSlot/SelectSlotEarliestTimeAndSameLocationIfB2B.swift +++ b/Sources/league-scheduling/data/selectSlot/SelectSlotEarliestTimeAndSameLocationIfB2B.swift @@ -1,13 +1,13 @@ struct SelectSlotEarliestTimeAndSameLocationIfB2B: SelectSlotProtocol, ~Copyable { - func select( + func select( team1: Entry.IDValue, team2: Entry.IDValue, assignedTimes: AssignedTimes, assignedLocations: AssignedLocations, - playsAtTimes: PlaysAtTimes, + playsAtTimes: borrowing PlaysAtTimesArray, playsAtLocations: PlaysAtLocations, - playableSlots: inout Set + playableSlots: inout some SetOfAvailableSlots & ~Copyable ) -> AvailableSlot? { guard !playableSlots.isEmpty else { return nil } let homePlaysAtTimes = playsAtTimes[unchecked: team1] @@ -51,7 +51,7 @@ struct SelectSlotEarliestTimeAndSameLocationIfB2B: SelectSlotProtocol, ~Copyable } else { nonBackToBackSlots.append(targetSlot) } - playableSlots.remove(targetSlot) + playableSlots.removeMember(targetSlot) } return nonBackToBackSlots.first } diff --git a/Sources/league-scheduling/data/selectSlot/SelectSlotNormal.swift b/Sources/league-scheduling/data/selectSlot/SelectSlotNormal.swift index e619b05..5889166 100644 --- a/Sources/league-scheduling/data/selectSlot/SelectSlotNormal.swift +++ b/Sources/league-scheduling/data/selectSlot/SelectSlotNormal.swift @@ -1,13 +1,13 @@ struct SelectSlotNormal: SelectSlotProtocol, ~Copyable { - func select( + func select( team1: Entry.IDValue, team2: Entry.IDValue, assignedTimes: AssignedTimes, assignedLocations: AssignedLocations, - playsAtTimes: PlaysAtTimes, + playsAtTimes: borrowing PlaysAtTimesArray, playsAtLocations: PlaysAtLocations, - playableSlots: inout Set + playableSlots: inout some SetOfAvailableSlots & ~Copyable ) -> AvailableSlot? { return Self.select( team1: team1, @@ -26,7 +26,7 @@ extension SelectSlotNormal { team2: Entry.IDValue, assignedTimes: AssignedTimes, assignedLocations: AssignedLocations, - playableSlots: Set + playableSlots: borrowing some SetOfAvailableSlots & ~Copyable ) -> AvailableSlot? { guard !playableSlots.isEmpty else { return nil } let team1Times = assignedTimes[unchecked: team1] @@ -48,14 +48,18 @@ extension SelectSlotNormal { team1Locations: AssignedLocations.Element, team2Times: AssignedTimes.Element, team2Locations: AssignedLocations.Element, - playableSlots: Set + playableSlots: borrowing some SetOfAvailableSlots & ~Copyable ) -> AvailableSlot? { - var selected = getSelectedSlot(playableSlots[playableSlots.startIndex], team1Times, team1Locations, team2Times, team2Locations) - for slot in playableSlots[playableSlots.index(after: playableSlots.startIndex)...] { - let minimum = getMinimumAssigned(slot, team1Times, team1Locations, team2Times, team2Locations) - if minimum <= selected.minimumAssigned { - selected.slot = slot - selected.minimumAssigned = minimum + var selected:SelectedSlot! = nil + playableSlots.forEach { slot in + if selected == nil { + selected = getSelectedSlot(slot, team1Times, team1Locations, team2Times, team2Locations) + } else { + let minimum = getMinimumAssigned(slot, team1Times, team1Locations, team2Times, team2Locations) + if minimum <= selected.minimumAssigned { + selected.slot = slot + selected.minimumAssigned = minimum + } } } return selected.slot diff --git a/Sources/league-scheduling/data/selectSlot/SelectSlotProtocol.swift b/Sources/league-scheduling/data/selectSlot/SelectSlotProtocol.swift index cf26dd5..4c3172c 100644 --- a/Sources/league-scheduling/data/selectSlot/SelectSlotProtocol.swift +++ b/Sources/league-scheduling/data/selectSlot/SelectSlotProtocol.swift @@ -1,12 +1,12 @@ protocol SelectSlotProtocol: Sendable, ~Copyable { - func select( + func select( team1: Entry.IDValue, team2: Entry.IDValue, assignedTimes: AssignedTimes, assignedLocations: AssignedLocations, - playsAtTimes: PlaysAtTimes, + playsAtTimes: borrowing PlaysAtTimesArray, playsAtLocations: PlaysAtLocations, - playableSlots: inout Set + playableSlots: inout some SetOfAvailableSlots & ~Copyable ) -> AvailableSlot? } \ No newline at end of file diff --git a/Sources/league-scheduling/extensions/OrderedSet+AbstractSet.swift b/Sources/league-scheduling/extensions/OrderedSet+AbstractSet.swift new file mode 100644 index 0000000..4726eee --- /dev/null +++ b/Sources/league-scheduling/extensions/OrderedSet+AbstractSet.swift @@ -0,0 +1,37 @@ + +import OrderedCollections + +extension OrderedSet: AbstractSet { + init(minimumCapacity: Int) { + self.init() + reserveCapacity(minimumCapacity) + } + + @inline(__always) + mutating func removeMember(_ member: Element) { + self.remove(member) + } + + @inline(__always) + mutating func removeAll() { + self.removeAll(keepingCapacity: false) + } + @inline(__always) + mutating func removeAllKeepingCapacity() { + self.removeAll(keepingCapacity: true) + } + + func forEachWithReturn(_ body: (Element) throws -> Result?) rethrows -> Result? { + for e in self { + if let r = try body(e) { + return r + } + } + return nil + } + + @inline(__always) + mutating func insertMember(_ member: Element) { + append(member) + } +} \ No newline at end of file diff --git a/Sources/league-scheduling/extensions/Set+AbstractSet.swift b/Sources/league-scheduling/extensions/Set+AbstractSet.swift new file mode 100644 index 0000000..bb44517 --- /dev/null +++ b/Sources/league-scheduling/extensions/Set+AbstractSet.swift @@ -0,0 +1,39 @@ + +extension Set: AbstractSet { + @inline(__always) + mutating func removeMember(_ member: Element) { + self.remove(member) + } + + @inline(__always) + mutating func removeAll() { + self.removeAll(keepingCapacity: false) + } + @inline(__always) + mutating func removeAllKeepingCapacity() { + self.removeAll(keepingCapacity: true) + } + + mutating func removeAll(where condition: (Element) throws -> Bool) rethrows { + var iterator = makeIterator() + while let next = iterator.next() { + if try condition(next) { + remove(next) + } + } + } + + func forEachWithReturn(_ body: (Element) throws -> Result?) rethrows -> Result? { + for e in self { + if let r = try body(e) { + return r + } + } + return nil + } + + @inline(__always) + mutating func insertMember(_ member: Element) { + insert(member) + } +} \ No newline at end of file diff --git a/Sources/league-scheduling/generated/Determinism.pb.swift b/Sources/league-scheduling/generated/Determinism.pb.swift new file mode 100644 index 0000000..f43db74 --- /dev/null +++ b/Sources/league-scheduling/generated/Determinism.pb.swift @@ -0,0 +1,154 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: Determinism.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +// 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. + +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +/// Constraints that influence how deterministic the schedule generation process is. +public struct LitLeagues_Leagues_Determinism: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var technique: UInt32 { + get {return _technique ?? 0} + set {_technique = newValue} + } + /// Returns true if `technique` has been explicitly set. + public var hasTechnique: Bool {return self._technique != nil} + /// Clears the value of `technique`. Subsequent reads from it will return its default value. + public mutating func clearTechnique() {self._technique = nil} + + public var seed: UInt64 { + get {return _seed ?? 0} + set {_seed = newValue} + } + /// Returns true if `seed` has been explicitly set. + public var hasSeed: Bool {return self._seed != nil} + /// Clears the value of `seed`. Subsequent reads from it will return its default value. + public mutating func clearSeed() {self._seed = nil} + + public var multiplier: UInt64 { + get {return _multiplier ?? 0} + set {_multiplier = newValue} + } + /// Returns true if `multiplier` has been explicitly set. + public var hasMultiplier: Bool {return self._multiplier != nil} + /// Clears the value of `multiplier`. Subsequent reads from it will return its default value. + public mutating func clearMultiplier() {self._multiplier = nil} + + public var increment: UInt64 { + get {return _increment ?? 0} + set {_increment = newValue} + } + /// Returns true if `increment` has been explicitly set. + public var hasIncrement: Bool {return self._increment != nil} + /// Clears the value of `increment`. Subsequent reads from it will return its default value. + public mutating func clearIncrement() {self._increment = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _technique: UInt32? = nil + fileprivate var _seed: UInt64? = nil + fileprivate var _multiplier: UInt64? = nil + fileprivate var _increment: UInt64? = nil +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +fileprivate let _protobuf_package = "lit_leagues.leagues" + +extension LitLeagues_Leagues_Determinism: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Determinism" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}technique\0\u{1}seed\0\u{1}multiplier\0\u{1}increment\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self._technique) }() + case 2: try { try decoder.decodeSingularUInt64Field(value: &self._seed) }() + case 3: try { try decoder.decodeSingularUInt64Field(value: &self._multiplier) }() + case 4: try { try decoder.decodeSingularUInt64Field(value: &self._increment) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._technique { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 1) + } }() + try { if let v = self._seed { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 2) + } }() + try { if let v = self._multiplier { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 3) + } }() + try { if let v = self._increment { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 4) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: LitLeagues_Leagues_Determinism, rhs: LitLeagues_Leagues_Determinism) -> Bool { + if lhs._technique != rhs._technique {return false} + if lhs._seed != rhs._seed {return false} + if lhs._multiplier != rhs._multiplier {return false} + if lhs._increment != rhs._increment {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Sources/league-scheduling/generated/GenerationConstraints.pb.swift b/Sources/league-scheduling/generated/GenerationConstraints.pb.swift index 7c097e9..76e59a1 100644 --- a/Sources/league-scheduling/generated/GenerationConstraints.pb.swift +++ b/Sources/league-scheduling/generated/GenerationConstraints.pb.swift @@ -95,6 +95,16 @@ public struct LitLeagues_Leagues_GenerationConstraints: Sendable { /// Clears the value of `regenerationAttemptsThreshold`. Subsequent reads from it will return its default value. public mutating func clearRegenerationAttemptsThreshold() {self._regenerationAttemptsThreshold = nil} + /// Deterministic constraints. If not provided, the output is non-deterministic (heavily relies on randomness and probabilities). + public var determinism: LitLeagues_Leagues_Determinism { + get {return _determinism ?? LitLeagues_Leagues_Determinism()} + set {_determinism = newValue} + } + /// Returns true if `determinism` has been explicitly set. + public var hasDeterminism: Bool {return self._determinism != nil} + /// Clears the value of `determinism`. Subsequent reads from it will return its default value. + public mutating func clearDeterminism() {self._determinism = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -103,6 +113,7 @@ public struct LitLeagues_Leagues_GenerationConstraints: Sendable { fileprivate var _regenerationAttemptsForFirstDay: UInt32? = nil fileprivate var _regenerationAttemptsForConsecutiveDay: UInt32? = nil fileprivate var _regenerationAttemptsThreshold: UInt32? = nil + fileprivate var _determinism: LitLeagues_Leagues_Determinism? = nil } // MARK: - Code below here is support for the SwiftProtobuf runtime. @@ -111,7 +122,7 @@ fileprivate let _protobuf_package = "lit_leagues.leagues" extension LitLeagues_Leagues_GenerationConstraints: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".GenerationConstraints" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}timeoutDelay\0\u{1}regenerationAttemptsForFirstDay\0\u{1}regenerationAttemptsForConsecutiveDay\0\u{1}regenerationAttemptsThreshold\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}timeoutDelay\0\u{1}regenerationAttemptsForFirstDay\0\u{1}regenerationAttemptsForConsecutiveDay\0\u{1}regenerationAttemptsThreshold\0\u{1}determinism\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -123,6 +134,7 @@ extension LitLeagues_Leagues_GenerationConstraints: SwiftProtobuf.Message, Swift case 2: try { try decoder.decodeSingularUInt32Field(value: &self._regenerationAttemptsForFirstDay) }() case 3: try { try decoder.decodeSingularUInt32Field(value: &self._regenerationAttemptsForConsecutiveDay) }() case 4: try { try decoder.decodeSingularUInt32Field(value: &self._regenerationAttemptsThreshold) }() + case 5: try { try decoder.decodeSingularMessageField(value: &self._determinism) }() default: break } } @@ -145,6 +157,9 @@ extension LitLeagues_Leagues_GenerationConstraints: SwiftProtobuf.Message, Swift try { if let v = self._regenerationAttemptsThreshold { try visitor.visitSingularUInt32Field(value: v, fieldNumber: 4) } }() + try { if let v = self._determinism { + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -153,6 +168,7 @@ extension LitLeagues_Leagues_GenerationConstraints: SwiftProtobuf.Message, Swift if lhs._regenerationAttemptsForFirstDay != rhs._regenerationAttemptsForFirstDay {return false} if lhs._regenerationAttemptsForConsecutiveDay != rhs._regenerationAttemptsForConsecutiveDay {return false} if lhs._regenerationAttemptsThreshold != rhs._regenerationAttemptsThreshold {return false} + if lhs._determinism != rhs._determinism {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/league-scheduling/generated/codable/Determinism+Codable.swift b/Sources/league-scheduling/generated/codable/Determinism+Codable.swift new file mode 100644 index 0000000..8c9dd07 --- /dev/null +++ b/Sources/league-scheduling/generated/codable/Determinism+Codable.swift @@ -0,0 +1,43 @@ + +#if ProtobufCodable +extension LitLeagues_Leagues_Determinism: Codable { + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let v = try container.decodeIfPresent(UInt32.self, forKey: .technique) { + technique = v + } + if let v = try container.decodeIfPresent(UInt64.self, forKey: .seed) { + seed = v + } + if let v = try container.decodeIfPresent(UInt64.self, forKey: .multiplier) { + multiplier = v + } + if let v = try container.decodeIfPresent(UInt64.self, forKey: .increment) { + increment = v + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if hasTechnique { + try container.encode(technique, forKey: .technique) + } + if hasSeed { + try container.encode(seed, forKey: .seed) + } + if hasMultiplier { + try container.encode(multiplier, forKey: .multiplier) + } + if hasIncrement { + try container.encode(increment, forKey: .increment) + } + } + + enum CodingKeys: CodingKey { + case technique + case seed + case multiplier + case increment + } +} +#endif \ No newline at end of file diff --git a/Sources/league-scheduling/generated/codable/GenerationConstraints+Codable.swift b/Sources/league-scheduling/generated/codable/GenerationConstraints+Codable.swift index 7372fde..4d5852f 100644 --- a/Sources/league-scheduling/generated/codable/GenerationConstraints+Codable.swift +++ b/Sources/league-scheduling/generated/codable/GenerationConstraints+Codable.swift @@ -15,6 +15,9 @@ extension GenerationConstraints: Codable { if let v = try container.decodeIfPresent(UInt32.self, forKey: .regenerationAttemptsThreshold) { regenerationAttemptsThreshold = v } + if let v = try container.decodeIfPresent(LitLeagues_Leagues_Determinism.self, forKey: .determinism) { + determinism = v + } } public func encode(to encoder: any Encoder) throws { @@ -31,6 +34,9 @@ extension GenerationConstraints: Codable { if hasRegenerationAttemptsThreshold { try container.encode(regenerationAttemptsThreshold, forKey: .regenerationAttemptsThreshold) } + if hasDeterminism { + try container.encode(determinism, forKey: .determinism) + } } enum CodingKeys: CodingKey { @@ -38,6 +44,7 @@ extension GenerationConstraints: Codable { case regenerationAttemptsForFirstDay case regenerationAttemptsForConsecutiveDay case regenerationAttemptsThreshold + case determinism } } #endif \ No newline at end of file diff --git a/Sources/league-scheduling/generated/extensions/Determinism+Extensions.swift b/Sources/league-scheduling/generated/extensions/Determinism+Extensions.swift new file mode 100644 index 0000000..b723e28 --- /dev/null +++ b/Sources/league-scheduling/generated/extensions/Determinism+Extensions.swift @@ -0,0 +1,22 @@ + +extension LitLeagues_Leagues_Determinism { + init( + technique: UInt32? = nil, + seed: UInt64? = nil, + multiplier: UInt64? = nil, + increment: UInt64? = nil + ) { + if let technique { + self.technique = technique + } + if let seed { + self.seed = seed + } + if let multiplier { + self.multiplier = multiplier + } + if let increment { + self.increment = increment + } + } +} \ No newline at end of file diff --git a/Sources/league-scheduling/generated/extensions/GenerationConstraints+Extensions.swift b/Sources/league-scheduling/generated/extensions/GenerationConstraints+Extensions.swift index 162bd51..87468e2 100644 --- a/Sources/league-scheduling/generated/extensions/GenerationConstraints+Extensions.swift +++ b/Sources/league-scheduling/generated/extensions/GenerationConstraints+Extensions.swift @@ -5,12 +5,16 @@ extension GenerationConstraints { timeoutDelay: UInt32, regenerationAttemptsForFirstDay: UInt32, regenerationAttemptsForConsecutiveDay: UInt32, - regenerationAttemptsThreshold: UInt32 + regenerationAttemptsThreshold: UInt32, + determinism: LitLeagues_Leagues_Determinism? ) { self.timeoutDelay = timeoutDelay self.regenerationAttemptsForFirstDay = regenerationAttemptsForFirstDay self.regenerationAttemptsForConsecutiveDay = regenerationAttemptsForConsecutiveDay self.regenerationAttemptsThreshold = regenerationAttemptsThreshold + if let determinism { + self.determinism = determinism + } } } @@ -20,6 +24,7 @@ extension GenerationConstraints { timeoutDelay: 60, regenerationAttemptsForFirstDay: 100, regenerationAttemptsForConsecutiveDay: 100, - regenerationAttemptsThreshold: 10_000 + regenerationAttemptsThreshold: 10_000, + determinism: nil ) } \ No newline at end of file diff --git a/Sources/league-scheduling/globals.swift b/Sources/league-scheduling/globals.swift new file mode 100644 index 0000000..043cc54 --- /dev/null +++ b/Sources/league-scheduling/globals.swift @@ -0,0 +1,24 @@ + +// MARK: adjacent times +func calculateAdjacentTimes( + for time: TimeIndex, + entryMatchupsPerGameDay: EntryMatchupsPerGameDay +) -> TimeSet { + var adjacentTimes = TimeSet() + let timeIndex = time % entryMatchupsPerGameDay + if timeIndex == 0 { + for i in 1..> typealias MaximumSameOpponentMatchupsCap = UInt32 -/// Remaining allocations allowed for a matchup pair, for a `DayIndex`. -/// -/// - Usage: [`Entry.IDValue`: `the number of remaining allocations`] -typealias RemainingAllocations = ContiguousArray> - /// When entries can play against each other again. /// /// - Usage: [`Entry.IDValue`: [opponent `Entry.IDValue`: `RecurringDayLimitInterval`]] @@ -70,17 +65,7 @@ typealias MaximumTimeAllocations = ContiguousArray> /// - Usage: [`Entry.IDValue`: [`LocationIndex`: `maximum allowed at LocationIndex`]] typealias MaximumLocationAllocations = ContiguousArray> -/// Times where an entry has already played at for the `day`. -/// -/// - Usage: [`Entry.IDValue`: `Set`] -typealias PlaysAtTimes = ContiguousArray> - /// Locations where an entry has already played at for the `day`. /// /// - Usage: [`Entry.IDValue`: `Set`] -typealias PlaysAtLocations = ContiguousArray> - -/// Slots where an entry has already played at for the `day`. -/// -/// - Usage: [`Entry.IDValue`: `Set`] -typealias PlaysAt = ContiguousArray> \ No newline at end of file +typealias PlaysAtLocations = ContiguousArray> \ No newline at end of file diff --git a/Sources/league-scheduling/util/FlippableMatchup.swift b/Sources/league-scheduling/util/FlippableMatchup.swift new file mode 100644 index 0000000..c57e349 --- /dev/null +++ b/Sources/league-scheduling/util/FlippableMatchup.swift @@ -0,0 +1,8 @@ + +/// A scheduled `Matchup` where the home and away teams can be swapped. +/// +/// Only used when balancing the final scheduled matchups' home/away. +struct FlippableMatchup: Hashable, Sendable { + let day:DayIndex + var matchup:Matchup +} \ No newline at end of file diff --git a/Sources/league-scheduling/util/LCG.swift b/Sources/league-scheduling/util/LCG.swift new file mode 100644 index 0000000..0938870 --- /dev/null +++ b/Sources/league-scheduling/util/LCG.swift @@ -0,0 +1,23 @@ + +/// Linear Congruential Generator. +struct LCG: RandomNumberGenerator, Sendable { + private var state:UInt64 + private let multiplier:UInt64 + private let increment:UInt64 + + init( + seed: UInt64, + multiplier: UInt64, + increment: UInt64 + ) { + self.state = seed == 0 ? 1 : seed + self.multiplier = multiplier + self.increment = increment + } + + mutating func next() -> UInt64 { + // LCG formula: state = (state * multiplier + increment) % modulus + state = state &* multiplier &+ increment + return state + } +} \ No newline at end of file diff --git a/Sources/league-scheduling/util/RedistributableMatchup.swift b/Sources/league-scheduling/util/RedistributableMatchup.swift new file mode 100644 index 0000000..f328244 --- /dev/null +++ b/Sources/league-scheduling/util/RedistributableMatchup.swift @@ -0,0 +1,9 @@ + +/// A scheduled `Matchup` that can be moved from its current day and slot to another. +/// +/// Only used when redistributing matchups. +struct RedistributableMatchup: Hashable, Sendable { + let fromDay:DayIndex + var matchup:Matchup + let toSlot:AvailableSlot +} \ No newline at end of file diff --git a/Sources/league-scheduling/util/array/PlaysAtTimesArray.swift b/Sources/league-scheduling/util/array/PlaysAtTimesArray.swift new file mode 100644 index 0000000..6f221d2 --- /dev/null +++ b/Sources/league-scheduling/util/array/PlaysAtTimesArray.swift @@ -0,0 +1,22 @@ + +struct PlaysAtTimesArray: Sendable { + internal private(set) var times:ContiguousArray + + subscript(unchecked index: some FixedWidthInteger) -> TimeSet { + times[unchecked: index] + } + + mutating func removeAllKeepingCapacity() { + for i in 0..) + init(minimumCapacity: Int) + + var count: Int { get } + var isEmpty: Bool { get } + + /// Returns a Boolean value that indicates whether the given element exists + /// in the set. + func contains(_ member: Element) -> Bool + + mutating func reserveCapacity(_ minimumCapacity: Int) + + /// Inserts the given element in the set if it is not already present. + mutating func insertMember(_ member: Element) + + /// Removes the specified element from the set. + mutating func removeMember(_ member: Element) + + mutating func removeAll() + mutating func removeAllKeepingCapacity() + mutating func removeAll(where condition: (Element) throws -> Bool) rethrows + + mutating func formUnion(_ other: borrowing Self) + + func randomElement() -> Element? + func randomElement(using: inout some RandomNumberGenerator) -> Element? + + func forEach(_ body: (Element) throws -> Void) rethrows + func forEachWithReturn(_ body: (Element) throws -> Result?) rethrows -> Result? + + //subscript(unchecked index: some FixedWidthInteger) -> Element { get set } + + func filter(_ closure: (Element) throws -> Bool) rethrows -> Self + + var first: Element? { get } + func first(where condition: (Element) throws -> Bool) rethrows -> Element? + + /// Returns a new set with the elements that are common to both this set and + /// the given sequence. + /// + /// In the following example, the `bothNeighborsAndEmployees` set is made up + /// of the elements that are in *both* the `employees` and `neighbors` sets. + /// Elements that are in only one or the other are left out of the result of + /// the intersection. + /// + /// let employees: Set = ["Alicia", "Bethany", "Chris", "Diana", "Eric"] + /// let neighbors: Set = ["Bethany", "Eric", "Forlani", "Greta"] + /// let bothNeighborsAndEmployees = employees.intersection(neighbors) + /// print(bothNeighborsAndEmployees) + /// // Prints "["Bethany", "Eric"]" + /// + /// - Parameter other: Another set. + /// - Returns: A new set. + func intersection(_ other: borrowing Self) -> Self + + func map(_ body: (Element) throws -> Result) rethrows -> [Result] +} \ No newline at end of file diff --git a/Sources/league-scheduling/util/set/SetOfAvailableSlots.swift b/Sources/league-scheduling/util/set/SetOfAvailableSlots.swift new file mode 100644 index 0000000..0ad1e9e --- /dev/null +++ b/Sources/league-scheduling/util/set/SetOfAvailableSlots.swift @@ -0,0 +1,8 @@ + +import OrderedCollections + +protocol SetOfAvailableSlots: AbstractSet, ~Copyable where Element == AvailableSlot { +} + +extension Set: SetOfAvailableSlots {} +extension OrderedSet: SetOfAvailableSlots {} \ No newline at end of file diff --git a/Sources/league-scheduling/util/set/SetOfEntryIDs.swift b/Sources/league-scheduling/util/set/SetOfEntryIDs.swift new file mode 100644 index 0000000..6fc7574 --- /dev/null +++ b/Sources/league-scheduling/util/set/SetOfEntryIDs.swift @@ -0,0 +1,60 @@ + +import OrderedCollections + +protocol SetOfEntryIDs: AbstractSet, ~Copyable where Element == Entry.IDValue { + /// - Returns: The available matchup pairs that can play for the `day`. + func availableMatchupPairs( + assignedEntryHomeAways: AssignedEntryHomeAways, + maxSameOpponentMatchups: MaximumSameOpponentMatchups + ) -> MatchupPairSet where MatchupPairSet.Element == MatchupPair +} + +extension Set: SetOfEntryIDs { + func availableMatchupPairs( + assignedEntryHomeAways: AssignedEntryHomeAways, + maxSameOpponentMatchups: MaximumSameOpponentMatchups + ) -> MatchupPairSet where MatchupPairSet.Element == MatchupPair { + guard !isEmpty else { return .init() } // https://github.com/apple/swift-collections/issues/608 + var pairs = MatchupPairSet() + pairs.reserveCapacity((count-1) * 2) + let sortedEntries = sorted() + var index = 0 + while index < sortedEntries.count - 1 { + let home = sortedEntries[unchecked: index] + index += 1 + let assignedHome = assignedEntryHomeAways[unchecked: home] + let maxSameOpponentMatchups = maxSameOpponentMatchups[unchecked: home] + for away in sortedEntries[index...] { + if assignedHome[unchecked: away].sum < maxSameOpponentMatchups[unchecked: away] { + pairs.insertMember(.init(team1: home, team2: away)) + } + } + } + return pairs + } +} + +extension OrderedSet: SetOfEntryIDs { + func availableMatchupPairs( + assignedEntryHomeAways: AssignedEntryHomeAways, + maxSameOpponentMatchups: MaximumSameOpponentMatchups + ) -> MatchupPairSet where MatchupPairSet.Element == MatchupPair { + guard !isEmpty else { return .init() } // https://github.com/apple/swift-collections/issues/608 + var pairs = MatchupPairSet() + pairs.reserveCapacity((count-1) * 2) + let sortedEntries = sorted() + var index = 0 + while index < sortedEntries.count - 1 { + let home = sortedEntries[unchecked: index] + index += 1 + let assignedHome = assignedEntryHomeAways[unchecked: home] + let maxSameOpponentMatchups = maxSameOpponentMatchups[unchecked: home] + for away in sortedEntries[index...] { + if assignedHome[unchecked: away].sum < maxSameOpponentMatchups[unchecked: away] { + pairs.insertMember(.init(team1: home, team2: away)) + } + } + } + return pairs + } +} \ No newline at end of file diff --git a/Sources/league-scheduling/util/set/SetOfUInt32.swift b/Sources/league-scheduling/util/set/SetOfUInt32.swift new file mode 100644 index 0000000..7b7a8f3 --- /dev/null +++ b/Sources/league-scheduling/util/set/SetOfUInt32.swift @@ -0,0 +1,11 @@ + +import OrderedCollections + +protocol SetOfUInt32: AbstractSet, ~Copyable where Element == UInt32 {} + +typealias SetOfDayIndexes = SetOfUInt32 +typealias SetOfTimeIndexes = SetOfUInt32 +typealias SetOfLocationIndexes = SetOfUInt32 + +extension Set: SetOfUInt32 {} +extension OrderedSet: SetOfUInt32 {} \ No newline at end of file diff --git a/Tests/LeagueSchedulingTests/CanPlayAtTests.swift b/Tests/LeagueSchedulingTests/CanPlayAtTests.swift index c0abd8c..dc415d8 100644 --- a/Tests/LeagueSchedulingTests/CanPlayAtTests.swift +++ b/Tests/LeagueSchedulingTests/CanPlayAtTests.swift @@ -1,5 +1,6 @@ @testable import LeagueScheduling +import OrderedCollections import StaticDateTimes import Testing @@ -11,8 +12,8 @@ struct CanPlayAtTests { let locations = 3 var gameGap = GameGap.upTo(1).minMax - var playsAt:PlaysAt.Element = [] - var playsAtTimes:PlaysAtTimes.Element = [] + var playsAt:some SetOfAvailableSlots = Set() + var playsAtTimes:OrderedSet = [] var timeNumbers:AssignedTimes.Element = .init(repeating: 0, count: times) var locationNumbers:AssignedLocations.Element = .init(repeating: 0, count: locations) let maxTimeNumbers:MaximumTimeAllocations.Element = .init(repeating: 1, count: times) @@ -46,8 +47,8 @@ struct CanPlayAtTests { )) } - playsAt.insert(AvailableSlot(time: 0, location: location)) - playsAtTimes.insert(0) + playsAt.insertMember(AvailableSlot(time: 0, location: location)) + playsAtTimes.append(0) #expect(!CanPlayAtNormal.test( time: 0, location: location, @@ -61,7 +62,7 @@ struct CanPlayAtTests { gameGap: gameGap )) - playsAt = [] + playsAt.removeAll() playsAtTimes = [] timeNumbers[0] = 1 #expect(!CanPlayAtNormal.test( @@ -98,7 +99,7 @@ extension CanPlayAtTests { ] var time:TimeIndex = 0 var location:LocationIndex = 0 - var playsAt:Set = [] + var playsAt:some SetOfAvailableSlots = Set() var gameGap = GameGap.upTo(5).minMax #expect(CanPlayAtWithTravelDurations.test( @@ -112,7 +113,7 @@ extension CanPlayAtTests { )) matchupDuration = .minutes(30) - playsAt = [AvailableSlot(time: 1, location: 0)] + playsAt = .init([AvailableSlot(time: 1, location: 0)]) #expect(CanPlayAtWithTravelDurations.test( startingTimes: startingTimes, matchupDuration: matchupDuration, diff --git a/Tests/LeagueSchedulingTests/DivisionMatchupCombinationTests.swift b/Tests/LeagueSchedulingTests/DivisionMatchupCombinationTests.swift index a5eb464..9f6dca5 100644 --- a/Tests/LeagueSchedulingTests/DivisionMatchupCombinationTests.swift +++ b/Tests/LeagueSchedulingTests/DivisionMatchupCombinationTests.swift @@ -14,7 +14,7 @@ struct DivisionMatchupCombinationTests { ] ] expected += expected - var combos = LeagueScheduleData.allDivisionMatchupCombinations( + var combos = calculateAllDivisionMatchupCombinations( entriesPerMatchup: 2, locations: 6, entryCountsForDivision: [12, 12] @@ -29,7 +29,7 @@ struct DivisionMatchupCombinationTests { [0, 5], [2, 3], [3, 2], [5, 0] ] ] - combos = LeagueScheduleData.allDivisionMatchupCombinations( + combos = calculateAllDivisionMatchupCombinations( entriesPerMatchup: 2, locations: 6, entryCountsForDivision: [14, 10] @@ -45,7 +45,7 @@ struct DivisionMatchupCombinationTests { [0, 5], [2, 3], [3, 2], [5, 0] ] ] - combos = LeagueScheduleData.allDivisionMatchupCombinations( + combos = calculateAllDivisionMatchupCombinations( entriesPerMatchup: 2, locations: 6, entryCountsForDivision: [14, 0, 10] @@ -75,7 +75,7 @@ extension DivisionMatchupCombinationTests { [6, 0], [0, 6] ] ] - var combos = LeagueScheduleData.allowedDivisionMatchupCombinations( + var combos = calculateAllowedDivisionMatchupCombinations( entriesPerMatchup: 2, locations: 6, entryCountsForDivision: [12, 12] @@ -90,7 +90,7 @@ extension DivisionMatchupCombinationTests { [4, 3], [2, 3] ] ] - combos = LeagueScheduleData.allowedDivisionMatchupCombinations( + combos = calculateAllowedDivisionMatchupCombinations( entriesPerMatchup: 2, locations: 6, entryCountsForDivision: [14, 10] diff --git a/Tests/LeagueSchedulingTests/MatchupBlockTests.swift b/Tests/LeagueSchedulingTests/MatchupBlockTests.swift index f96bbd6..a4c4571 100644 --- a/Tests/LeagueSchedulingTests/MatchupBlockTests.swift +++ b/Tests/LeagueSchedulingTests/MatchupBlockTests.swift @@ -1,5 +1,6 @@ @testable import LeagueScheduling +import OrderedCollections import StaticDateTimes import Testing @@ -11,36 +12,36 @@ struct MatchupBlockTests: ScheduleExpectations { extension MatchupBlockTests { @Test(.timeLimit(.minutes(1))) func adjacentTimes() { - var adjacent = LeagueScheduleData.adjacentTimes(for: 0, entryMatchupsPerGameDay: 2) + var adjacent:OrderedSet = calculateAdjacentTimes(for: 0, entryMatchupsPerGameDay: 2) #expect(adjacent == [1]) - adjacent = LeagueScheduleData.adjacentTimes(for: 0, entryMatchupsPerGameDay: 3) + adjacent = calculateAdjacentTimes(for: 0, entryMatchupsPerGameDay: 3) #expect(adjacent == [1, 2]) - adjacent = LeagueScheduleData.adjacentTimes(for: 0, entryMatchupsPerGameDay: 4) + adjacent = calculateAdjacentTimes(for: 0, entryMatchupsPerGameDay: 4) #expect(adjacent == [1, 2, 3]) - adjacent = LeagueScheduleData.adjacentTimes(for: 1, entryMatchupsPerGameDay: 2) + adjacent = calculateAdjacentTimes(for: 1, entryMatchupsPerGameDay: 2) #expect(adjacent == [0]) - adjacent = LeagueScheduleData.adjacentTimes(for: 1, entryMatchupsPerGameDay: 3) + adjacent = calculateAdjacentTimes(for: 1, entryMatchupsPerGameDay: 3) #expect(adjacent == [0, 2]) - adjacent = LeagueScheduleData.adjacentTimes(for: 1, entryMatchupsPerGameDay: 4) + adjacent = calculateAdjacentTimes(for: 1, entryMatchupsPerGameDay: 4) #expect(adjacent == [0, 2, 3]) - adjacent = LeagueScheduleData.adjacentTimes(for: 2, entryMatchupsPerGameDay: 2) + adjacent = calculateAdjacentTimes(for: 2, entryMatchupsPerGameDay: 2) #expect(adjacent == [3]) - adjacent = LeagueScheduleData.adjacentTimes(for: 2, entryMatchupsPerGameDay: 3) - #expect(adjacent == [0, 1]) + adjacent = calculateAdjacentTimes(for: 2, entryMatchupsPerGameDay: 3) + #expect(adjacent == [1, 0]) - adjacent = LeagueScheduleData.adjacentTimes(for: 2, entryMatchupsPerGameDay: 4) - #expect(adjacent == [0, 1, 3]) + adjacent = calculateAdjacentTimes(for: 2, entryMatchupsPerGameDay: 4) + #expect(adjacent == [1, 0, 3]) - adjacent = LeagueScheduleData.adjacentTimes(for: 2, entryMatchupsPerGameDay: 5) - #expect(adjacent == [0, 1, 3, 4]) + adjacent = calculateAdjacentTimes(for: 2, entryMatchupsPerGameDay: 5) + #expect(adjacent == [1, 0, 3, 4]) } } \ No newline at end of file diff --git a/Tests/LeagueSchedulingTests/schedules/ScheduleBeanBagToss.swift b/Tests/LeagueSchedulingTests/schedules/ScheduleBeanBagToss.swift index 97b0ebf..d4d07c2 100644 --- a/Tests/LeagueSchedulingTests/schedules/ScheduleBeanBagToss.swift +++ b/Tests/LeagueSchedulingTests/schedules/ScheduleBeanBagToss.swift @@ -17,7 +17,7 @@ struct ScheduleBeanBagToss: ScheduleTestsProtocol { ) } static func schedule8GameDays3Times3Locations1Division9Teams( - constraints: GenerationConstraints = .default + constraints: GenerationConstraints = .unitTestDefault ) throws -> RequestPayload.Runtime { let maxEntryMatchupsPerGameDay:EntryMatchupsPerGameDay = 2 let (gameDays, times, locations, teams):(DayIndex, TimeIndex, LocationIndex, Int) = (8, 3, 3, 9) @@ -397,7 +397,7 @@ extension ScheduleBeanBagToss { try expectations(settings: schedule, matchupsCount: 210, data: data) } static func scheduleBeanBagToss_10GameDays4Time8Locations1Division21Teams( - constraints: GenerationConstraints = .default + constraints: GenerationConstraints = .unitTestDefault ) throws -> RequestPayload.Runtime { let maxEntryMatchupsPerGameDay:EntryMatchupsPerGameDay = 2 let (gameDays, times, locations, teams):(DayIndex, TimeIndex, LocationIndex, Int) = (10, 4, 8, 21) @@ -443,7 +443,7 @@ extension ScheduleBeanBagToss { try expectations(settings: schedule, matchupsCount: 230, data: data) } static func scheduleBeanBagToss_10GameDays4Times6Locations2Division23Teams( - constraints: GenerationConstraints = .default + constraints: GenerationConstraints = .unitTestDefault ) throws -> RequestPayload.Runtime { let maxEntryMatchupsPerGameDay:EntryMatchupsPerGameDay = 2 let (gameDays, times, locations, teams):(DayIndex, TimeIndex, LocationIndex, Int) = (10, 4, 6, 23) diff --git a/Tests/LeagueSchedulingTests/schedules/expectations/ScheduleExpectations.swift b/Tests/LeagueSchedulingTests/schedules/expectations/ScheduleExpectations.swift index a34f2e7..9f6d946 100644 --- a/Tests/LeagueSchedulingTests/schedules/expectations/ScheduleExpectations.swift +++ b/Tests/LeagueSchedulingTests/schedules/expectations/ScheduleExpectations.swift @@ -5,6 +5,16 @@ import Testing protocol ScheduleExpectations: Sendable { } +extension GenerationConstraints { + static let unitTestDefault = Self( + timeoutDelay: Self.default.timeoutDelay, + regenerationAttemptsForFirstDay: Self.default.regenerationAttemptsForFirstDay, + regenerationAttemptsForConsecutiveDay: Self.default.regenerationAttemptsForConsecutiveDay, + regenerationAttemptsThreshold: Self.default.regenerationAttemptsThreshold, + determinism: nil//.init(seed: 69) + ) +} + // MARK: Expectations extension ScheduleExpectations { func expectations( @@ -13,8 +23,9 @@ extension ScheduleExpectations { data: LeagueGenerationResult ) throws { guard !Task.isCancelled else { return } + let determinism = settings.constraints.determinism let regenerationAttempts:String = data.results.map { - "assignLocationTimeRegenerationAttempts=\($0.assignLocationTimeRegenerationAttempts);negativeDayIndexRegenerationAttempts=\($0.negativeDayIndexRegenerationAttempts)" + "hasDeterminism=\(settings.constraints.hasDeterminism),technique=\(determinism.technique),seed=\(determinism.seed);assignLocationTimeRegenerationAttempts=\($0.assignLocationTimeRegenerationAttempts);negativeDayIndexRegenerationAttempts=\($0.negativeDayIndexRegenerationAttempts)" }.joined(separator: "\n") if false { for result in data.results { diff --git a/Tests/LeagueSchedulingTests/schedules/util/ScheduleTestsProtocol.swift b/Tests/LeagueSchedulingTests/schedules/util/ScheduleTestsProtocol.swift index be33b2a..6e6f132 100644 --- a/Tests/LeagueSchedulingTests/schedules/util/ScheduleTestsProtocol.swift +++ b/Tests/LeagueSchedulingTests/schedules/util/ScheduleTestsProtocol.swift @@ -86,7 +86,7 @@ extension ScheduleTestsProtocol { divisionsCanPlayOnSameDay: Bool = true, divisionsCanPlayAtSameTime: Bool = true, entries: [Entry.Runtime], - constraints: GenerationConstraints = .default + constraints: GenerationConstraints = .unitTestDefault ) -> RequestPayload.Runtime { let correctMaximumPlayableMatchups = RequestPayload.calculateMaximumPlayableMatchups( gameDays: gameDays, diff --git a/Tests/LeagueSchedulingTests/schedules/util/ValidLeagueMatchup.swift b/Tests/LeagueSchedulingTests/schedules/util/ValidLeagueMatchup.swift index a9c921f..59cf459 100644 --- a/Tests/LeagueSchedulingTests/schedules/util/ValidLeagueMatchup.swift +++ b/Tests/LeagueSchedulingTests/schedules/util/ValidLeagueMatchup.swift @@ -1,7 +1,11 @@ @testable import LeagueScheduling -struct ValidLeagueMatchup: Hashable { +struct ValidLeagueMatchup: CustomStringConvertible, Hashable { let day:DayIndex let matchup:Matchup + + var description: String { + "ValidLeagueMatchup(day: \(day), matchup: \(matchup.description))" + } } \ No newline at end of file