Skip to content

Commit

Permalink
Merge branch 'main' of github.com:justeattakeaway/Genything
Browse files Browse the repository at this point in the history
  • Loading branch information
nicorichard committed Dec 7, 2021
2 parents 40ebbc3 + bc543ef commit c394511
Show file tree
Hide file tree
Showing 17 changed files with 283 additions and 60 deletions.
5 changes: 1 addition & 4 deletions Sources/Genything/gen/Gen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>: Identifiable {
/// A stable identity for this generator
public let id = UUID()

public struct Gen<T> {
/// A callback capable of generating a new value using the provided `Context`
fileprivate let generator: (Context) throws -> T

Expand Down
45 changes: 45 additions & 0 deletions Sources/Genything/gen/build/Gen+exhaust.swift
Original file line number Diff line number Diff line change
@@ -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<T>) -> Gen<T> {
let id = UUID()
return Gen<T> { 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)
}
}
}
}
6 changes: 5 additions & 1 deletion Sources/Genything/gen/build/Gen+of.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
Gen<T> {
assert(!values.isEmpty, "`Gen.of(values:)` was invoked with an empty list of values")

return Gen<T> {
values.randomElement(using: &$0.rng)!
}
}
Expand Down
14 changes: 1 addition & 13 deletions Sources/Genything/gen/combine/Gen+either.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,6 @@ public extension Gen {
///
/// - Returns: The generator
static func either(left: Gen<T>, right: Gen<T>, rightProbability: Double = 0.5) -> Gen<T> {
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<Double>.from(probabilityRange).flatMap {
if $0 <= rightProbability {
return right
} else {
return left
}
}
left.or(right, otherProbability: rightProbability)
}
}
28 changes: 28 additions & 0 deletions Sources/Genything/gen/combine/Gen+or.swift
Original file line number Diff line number Diff line change
@@ -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<T>, otherProbability: Double = 0.5) -> Gen<T> {
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<Double>.from(probabilityRange).flatMap {
if $0 <= otherProbability {
return other
} else {
return self
}
}
}
}
6 changes: 2 additions & 4 deletions Sources/Genything/gen/context/Context+Determinism.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
}
10 changes: 5 additions & 5 deletions Sources/Genything/gen/context/Context.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions Sources/Genything/gen/context/ContextServiceLocator.swift
Original file line number Diff line number Diff line change
@@ -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<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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@ 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
///
/// 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
}
Expand Down
41 changes: 31 additions & 10 deletions Sources/Genything/gen/mutate/Gen+unique.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Equatable>(_ value: T, id: UUID) -> Bool {
data[id]?.contains {
($0 as? T) == value
} ?? false
}

func store<T: Equatable>(_ 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`
Expand All @@ -18,26 +40,25 @@ public extension Gen where T: Equatable {
///
/// - Returns: A `Gen` generator.
func unique() -> Gen<T> {
let id = UUID()
return Gen<T> { 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
}
Expand Down
12 changes: 12 additions & 0 deletions Tests/GenythingTests/assertions/assertAcceptableDelta.swift
Original file line number Diff line number Diff line change
@@ -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))")
}
45 changes: 45 additions & 0 deletions Tests/GenythingTests/gen/build/GenExhaustTests.swift
Original file line number Diff line number Diff line change
@@ -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..<count {
XCTAssertTrue(3...10 ~= values[i])
}
}

func test_n_exhaustives_1_context() {
let other = Gen.from(3...10)
let gen1 = Gen.exhaust([0, 1, 2], then: other)
let gen2 = Gen.exhaust([0, 1, 2], then: other)

let count = 100
let values = gen1.zip(with: gen2).take(count: count)

XCTAssertEqual(0, values[0].0)
XCTAssertEqual(0, values[0].1)

XCTAssertEqual(1, values[1].0)
XCTAssertEqual(1, values[1].1)

XCTAssertEqual(2, values[2].0)
XCTAssertEqual(2, values[2].1)

for i in 3..<count {
XCTAssertTrue(3...10 ~= values[i].0)
XCTAssertTrue(3...10 ~= values[i].1)
}
}
}
11 changes: 11 additions & 0 deletions Tests/GenythingTests/gen/build/GenOfTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,15 @@ final internal class GenOfTests: XCTestCase {
XCTAssert(sample.contains(2))
XCTAssert(sample.allSatisfy { $0 == 0 || $0 == 1 || $0 == 2 })
}


func test_exhaust() {
let samples = Gen.exhaust([0, Int.max], then: Gen.from(0...Int.max))
.samples()

XCTAssertEqual(0, samples[0])
XCTAssertEqual(Int.max, samples[1])

print("!@# samples \(samples)")
}
}
20 changes: 10 additions & 10 deletions Tests/GenythingTests/gen/combine/GenOneOfTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ import XCTest
@testable import Genything

final internal class GenOneOfTests: XCTestCase {
func test_oneOf() {
let gen1 = Gen.constant(0)
let gen2 = Gen.constant(1)
func test_oneOf_generates_equally() {
let gen0 = Gen.constant(0)
let gen1 = Gen.constant(1)

let iterations = 1000
let iterations = 10000

// Count which bucket we are taking from
let count = Gen.one(of: [gen1, gen2]).take(count: iterations).filter { $0 == 0 }.count
let countGen0 = Gen
.one(of: [gen0, gen1])
.take(count: iterations)
.filter { $0 == 0 }
.count

let expectedCount = iterations / 2 // We are expecting exactly half the values to be from bucket 0
let acceptableDelta = 50 // Allow for the count to be +- 50
let acceptableRange = (expectedCount-acceptableDelta)...(expectedCount+acceptableDelta)

XCTAssert(acceptableRange ~= count)
assertAcceptableDelta(total: iterations, actual: countGen0, acceptablePercentDelta: 0.01)
}
}
Loading

0 comments on commit c394511

Please sign in to comment.