diff --git a/Sources/Genything/gen/Gen.swift b/Sources/Genything/gen/Gen.swift index b6502ebc..99191d26 100644 --- a/Sources/Genything/gen/Gen.swift +++ b/Sources/Genything/gen/Gen.swift @@ -3,10 +3,7 @@ import Foundation // MARK: Gen Typeclass /// A type class capable of generating a value of type `T` from a given `Context` -public struct Gen: Identifiable { - /// A stable identity for this generator - public let id = UUID() - +public struct Gen { /// A callback capable of generating a new value using the provided `Context` fileprivate let generator: (Context) throws -> T diff --git a/Sources/Genything/gen/build/Gen+exhaust.swift b/Sources/Genything/gen/build/Gen+exhaust.swift new file mode 100644 index 00000000..1e7fce67 --- /dev/null +++ b/Sources/Genything/gen/build/Gen+exhaust.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Storage required in order to index all exhaustive values before moving on to the `then` generator +/// Bounded by the lifetime of the `Context` +/// +/// - SeeAlso: `Gen.exhaust(values:then:)` +private class ExhaustiveStorage { + /// Map of generator identifier to Index + private var data: [UUID:Int] = [:] + + /// Returns: The current exhaustive index for the provided generator's ID + func index(_ id: UUID) -> Int { + data[id] ?? 0 + } + + /// Increments the index for the provided generator's ID + func incrementIndex(_ id: UUID) { + data[id] = index(id) + 1 + } +} + +public extension Gen { + /// Returns: A generator which produces (in-order) all of the values from the provided list, then randomly from the provided generator + /// + /// - Parameters: + /// - values: A list of values we will always draw from first + /// - then: A generator to produce values after we have exhausted the `values` list + /// + /// - Returns: The generator + static func exhaust(_ values: [T], then: Gen) -> Gen { + let id = UUID() + return Gen { ctx in + let store = ctx.services.service { + ExhaustiveStorage() + } + let i = store.index(id) + if i < values.count { + store.incrementIndex(id) + return values[i] + } else { + return then.generate(context: ctx) + } + } + } +} diff --git a/Sources/Genything/gen/build/Gen+of.swift b/Sources/Genything/gen/build/Gen+of.swift index 0b18e672..ed2e2608 100644 --- a/Sources/Genything/gen/build/Gen+of.swift +++ b/Sources/Genything/gen/build/Gen+of.swift @@ -5,11 +5,15 @@ import Foundation public extension Gen { /// Returns: A generator which randomly produces values form the provided list /// + /// - Warning: The values list must not be empty + /// /// - Parameter values: The values which this generator will randomly select from /// /// - Returns: The generator static func of(_ values: [T]) -> Gen { - Gen { + assert(!values.isEmpty, "`Gen.of(values:)` was invoked with an empty list of values") + + return Gen { values.randomElement(using: &$0.rng)! } } diff --git a/Sources/Genything/gen/combine/Gen+either.swift b/Sources/Genything/gen/combine/Gen+either.swift index 8fede416..bf7c3980 100644 --- a/Sources/Genything/gen/combine/Gen+either.swift +++ b/Sources/Genything/gen/combine/Gen+either.swift @@ -12,18 +12,6 @@ public extension Gen { /// /// - Returns: The generator static func either(left: Gen, right: Gen, rightProbability: Double = 0.5) -> Gen { - let probabilityRange = 0.0...1.0 - assert( - probabilityRange.contains(rightProbability), - "A probability between 0.0 and 1.0 must be specified. Found: \(rightProbability)" - ) - - return Gen.from(probabilityRange).flatMap { - if $0 <= rightProbability { - return right - } else { - return left - } - } + left.or(right, otherProbability: rightProbability) } } diff --git a/Sources/Genything/gen/combine/Gen+or.swift b/Sources/Genything/gen/combine/Gen+or.swift new file mode 100644 index 00000000..1a2877fb --- /dev/null +++ b/Sources/Genything/gen/combine/Gen+or.swift @@ -0,0 +1,28 @@ +import Foundation + +// MARK: Combine + +public extension Gen { + /// Returns: A generator which randomly selects values from either the receiver or the `other` generator + /// + /// - Parameters: + /// - other: Another generator which may get selected to produce values + /// - otherProbability: The probability that the the right generator will be selected from + /// + /// - Returns: The generator + func or(_ other: Gen, otherProbability: Double = 0.5) -> Gen { + let probabilityRange = 0.0...1.0 + assert( + probabilityRange.contains(otherProbability), + "A probability between 0.0 and 1.0 must be specified. Found: \(otherProbability)" + ) + + return Gen.from(probabilityRange).flatMap { + if $0 <= otherProbability { + return other + } else { + return self + } + } + } +} diff --git a/Sources/Genything/gen/context/Context+Determinism.swift b/Sources/Genything/gen/context/Context+Determinism.swift index e32ef68c..11e19822 100644 --- a/Sources/Genything/gen/context/Context+Determinism.swift +++ b/Sources/Genything/gen/context/Context+Determinism.swift @@ -6,8 +6,6 @@ public enum Determinism { /// Subsequent runs using the same `Context` are guaranteed to produce values in the same order case predetermined(seed: UInt64) - // TODO: Create a mechanism to log `originalSeed` to allow for replay using .predetermined. - /// A random `Determinism` seeded by a random value /// Subsequent runs using the same `Context` will generate completely different random values case random @@ -18,10 +16,10 @@ public extension Context { convenience init(determinism: Determinism) { switch determinism { case let .predetermined(seed): - self.init(using: LCRNG(seed: seed), originalSeed: seed) + self.init(using: LinearCongruentialRandomNumberGenerator(seed: seed), originalSeed: seed) case .random: let seed = UInt64(arc4random()) - self.init(using: LCRNG(seed: seed), originalSeed: seed) + self.init(using: LinearCongruentialRandomNumberGenerator(seed: seed), originalSeed: seed) } } } diff --git a/Sources/Genything/gen/context/Context.swift b/Sources/Genything/gen/context/Context.swift index b833438e..c870e344 100644 --- a/Sources/Genything/gen/context/Context.swift +++ b/Sources/Genything/gen/context/Context.swift @@ -5,7 +5,6 @@ import Foundation /// It's main purpose is to hold onto the Random Number Generator `rng`, so that as generations occur the RNG's state changes are propogated to each generator /// /// - Note: The context can be held onto by a user to keep track of `rng`'s state between generations -/// public class Context { // MARK: Randomness @@ -48,12 +47,13 @@ public class Context { self.originalSeed = originalSeed } - /// A cache capable of storing the unique values created by a particular Generator's id for the lifetime of the `Context` + /// A service locator and cache capable of storing services for the lifetime of the context /// - /// - Note: At the moment only Generators which have been `unique`'d will add values to the cache + /// Gives the `Context` and it's Generators their super-powers! /// - /// - SeeAlso: `Gen.unique()` - internal var uniqueCache: [UUID:[Any]] = [:] + /// - SeeAlso: `Gen.unique` + /// - SeeAlso: `Gen.exhaust` + internal let services: ContextServiceLocator = ContextServiceLocator() } // MARK: Convenience Context creators diff --git a/Sources/Genything/gen/context/ContextServiceLocator.swift b/Sources/Genything/gen/context/ContextServiceLocator.swift new file mode 100644 index 00000000..7d347e10 --- /dev/null +++ b/Sources/Genything/gen/context/ContextServiceLocator.swift @@ -0,0 +1,23 @@ +import Foundation + +/// A service locator and cache which allows a context to hold onto arbitrary services for it's lifetime +final class ContextServiceLocator { + private var store = [Int : Any]() + + /// Locates a service of type `Service`, creating it via `factory` if it does not exist already + /// + /// - Parameter factory: A factory capable of creating the service + /// + /// - Returns: The service + func service(_ factory: @escaping () -> Service) -> Service { + let key = Int(bitPattern: ObjectIdentifier(Service.self)) + + if let service = store[key] as? Service { + return service + } else { + let service = factory() + store[key] = service + return service + } + } +} diff --git a/Sources/Genything/gen/context/rng/LCRNG.swift b/Sources/Genything/gen/context/rng/LinearCongruentialRandomNumberGenerator.swift similarity index 87% rename from Sources/Genything/gen/context/rng/LCRNG.swift rename to Sources/Genything/gen/context/rng/LinearCongruentialRandomNumberGenerator.swift index f26cc5b5..9672e7f8 100644 --- a/Sources/Genything/gen/context/rng/LCRNG.swift +++ b/Sources/Genything/gen/context/rng/LinearCongruentialRandomNumberGenerator.swift @@ -7,7 +7,7 @@ import Foundation /// /// - SeeAlso: https://nuclear.llnl.gov/CNP/rng/rngman/node4.html /// - SeeAlso: https://en.wikipedia.org/wiki/Linear_congruential_generator -public struct LCRNG: RandomNumberGenerator { +struct LinearCongruentialRandomNumberGenerator: RandomNumberGenerator { private var seed: UInt64 /// Initializes a `LCRNG` with the provided seed @@ -15,13 +15,13 @@ public struct LCRNG: RandomNumberGenerator { /// Any two `LCRNG` instances initialized by the same seed will independently generate the same sequence of pseudo-random /// /// - Parameter seed: The seed value which should be used to start the generator - public init(seed: UInt64) { + init(seed: UInt64) { self.seed = seed } private let a: UInt64 = 2862933555777941757 private let b: UInt64 = 3037000493 - public mutating func next() -> UInt64 { + mutating func next() -> UInt64 { seed = a &* seed &+ b return seed } diff --git a/Sources/Genything/gen/mutate/Gen+unique.swift b/Sources/Genything/gen/mutate/Gen+unique.swift index b22df4e2..4b6a2d8b 100644 --- a/Sources/Genything/gen/mutate/Gen+unique.swift +++ b/Sources/Genything/gen/mutate/Gen+unique.swift @@ -2,7 +2,29 @@ import Foundation // MARK: Mutate +/// A cache capable of storing the unique values created by a particular Generator's id for the lifetime of the `Context` +/// +/// - SeeAlso: `Gen.unique()` +private class UniqueStorage { + private var data: [UUID:[Any]] = [:] + + func contains(_ value: T, id: UUID) -> Bool { + data[id]?.contains { + ($0 as? T) == value + } ?? false + } + + func store(_ value: T, id: UUID) { + if let existing = data[id] { + data[id] = existing + [value] + } else { + data[id] = [value] + } + } +} + public extension Gen where T: Equatable { + /// Returns: A generator that only produces unique values /// /// - Warning: If the unique'd Generator is small enough this function will throw `UniqueError.maxDepthReached` @@ -18,26 +40,25 @@ public extension Gen where T: Equatable { /// /// - Returns: A `Gen` generator. func unique() -> Gen { + let id = UUID() return Gen { ctx in + // Initializes or fetches a `UniqueStorage` store for this generator's ID + let store = ctx.services.service { + UniqueStorage() + } + let value = (0...ctx.maxDepth).lazy.map { _ in generate(context: ctx) }.first { candidateValue in - let exists = ctx.uniqueCache[id]?.contains { - ($0 as? T) == candidateValue - } ?? false - - return !exists + !store.contains(candidateValue, id: id) } guard let value = value else { throw GenError.maxDepthReached } - if let cache = ctx.uniqueCache[id] { - ctx.uniqueCache[id] = cache + [value] - } else { - ctx.uniqueCache[id] = [value] - } + // Saves this value to the store + store.store(value, id: id) return value } diff --git a/Tests/GenythingTests/assertions/assertAcceptableDelta.swift b/Tests/GenythingTests/assertions/assertAcceptableDelta.swift new file mode 100644 index 00000000..8069c429 --- /dev/null +++ b/Tests/GenythingTests/assertions/assertAcceptableDelta.swift @@ -0,0 +1,12 @@ +import Foundation +import XCTest + +func assertAcceptableDelta(total: Int, actual: Int, acceptablePercentDelta: Double) { + let expected = total / 2 + let delta = abs(expected - actual) + + XCTAssert( + /// Ensure that we generate nearly 50% of each value + 0...Int(Double(actual) * acceptablePercentDelta) ~= delta, + "Expected to generate true with ~0.5 probability (± \(acceptablePercentDelta), received ± \(Double(delta) / Double(total))") +} diff --git a/Tests/GenythingTests/gen/build/GenExhaustTests.swift b/Tests/GenythingTests/gen/build/GenExhaustTests.swift new file mode 100644 index 00000000..3e14498f --- /dev/null +++ b/Tests/GenythingTests/gen/build/GenExhaustTests.swift @@ -0,0 +1,45 @@ +import XCTest +import Genything +import GenythingTest + +final internal class GenExhaustTests: XCTestCase { + func test_the_exhaustion_of_values() { + let gen = Gen.exhaust([0, 1, 2], then: .from(3...10)) + + gen.assertForAll { 0...10 ~= $0 } + + let count = 100 + let values = gen.take(count: count) + + XCTAssertEqual(0, values[0]) + XCTAssertEqual(1, values[1]) + XCTAssertEqual(2, values[2]) + + for i in 3..