diff --git a/CHANGELOG.md b/CHANGELOG.md index 058f0f52..ba7e04e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## BusinessMath Library +### [2.1.4] - 2026-04-07 + +**Version 2.1.4** is a follow-up developer-hygiene release that completes +three cleanup items surfaced by the v2.1.3 work but deferred at the time: +the `Examples/` directory format-string violations, the unseeded +`generateRandomReturns` production function, and the duplicated local +`SeededRNG` declarations across 5 test files. + +#### Fixed + +- **`Examples/` directory `String(format:)` cleanup.** Both + `MultipleLinearRegressionExample.swift` (~22 calls) and + `LinearRegressionConvenienceExample.swift` (~18 calls) now use the + project's `value.number(N)` extension instead of the banned C-style + format ABI. Examples are not part of the package build target, so + these violations didn't fail CI before — but they were user-facing + reference code that propagated the bad pattern. Now consistent with + the rest of the codebase. + +- **5 test files no longer declare a local `struct SeededRNG`.** The + duplicated MMIX-LCG generator now lives in a single canonical location + in `Tests/TestSupport/SeededRNG.swift` as the new `MMIXSeededRNG` + type. Bit-identical sequences preserved — no test assertions needed + re-tuning. The 5 files affected are: + - `Tests/BusinessMathTests/Statistics Tests/Descriptor Tests/Descriptives Tests.swift` + - `Tests/BusinessMathTests/Statistics Tests/Descriptor Tests/Dispersion Around the Mean Tests/Dispersion Around the Mean Tests.swift` + - `Tests/BusinessMathTests/Statistics Tests/Regression Tests/LinearRegressionConvenienceTests.swift` + - `Tests/BusinessMathTests/Statistics Tests/Regression Tests/DenseMatrixTests.swift` + - `Tests/BusinessMathTests/Statistics Tests/NonlinearRegressionTests.swift` + + The new `TestSupport.MMIXSeededRNG` is a value type (struct) with + `mutating func next()` and `mutating func nextSigned()` — the latter + matches the `[-1, 1]` mapping that `DenseMatrixTests` previously used + inline. + +#### Added + +- **`generateRandomReturns(count:mean:stdDev:using:)`** — additive + overload of the existing `generateRandomReturns(count:mean:stdDev:)` + that accepts an explicit `RandomNumberGenerator`. Lets callers + (especially tests) supply a deterministic generator for reproducibility. + The unseeded original is now a thin wrapper that creates a + `SystemRandomNumberGenerator` and calls the seeded overload — same + observable behavior for existing callers, no breaking change. Also + fixed an edge case where `Double.random(in: 0.0...1.0)` returning + exactly 0 would cause `log(0) = -inf` in the Box-Muller transform; + guarded with `Double.leastNormalMagnitude`. + +- **`TestSupport.MMIXSeededRNG`** — see above. + +#### Notes + +- Purely additive at the public API level. No types renamed, no + signatures changed. +- All 4817 tests from v2.1.3 continue to pass after the consolidation. +- DocC `.md` files in `Sources/BusinessMath/BusinessMath.docc/` still + contain ~62 `String(format:)` instances in their Swift code samples. + These are documentation, not compiled, but they propagate the bad + pattern to users who copy from the docs. Tracked as a future v2.1.5 + cleanup. + ### [2.1.3] - 2026-04-07 **Version 2.1.3** is a developer-hygiene release that cleans up the diff --git a/Examples/LinearRegressionConvenienceExample.swift b/Examples/LinearRegressionConvenienceExample.swift index de566c62..87f5826f 100644 --- a/Examples/LinearRegressionConvenienceExample.swift +++ b/Examples/LinearRegressionConvenienceExample.swift @@ -26,19 +26,19 @@ public func runLinearRegressionConvenienceExamples() { do { let result = try linearRegression(x: advertisingSpend, y: sales) - print("Model: Sales = \(String(format: "%.2f", result.intercept)) + \(String(format: "%.2f", result.coefficients[0]))×Advertising\n") + print("Model: Sales = \(result.intercept.number(2)) + \(result.coefficients[0].number(2))×Advertising\n") print("Diagnostics:") - print(" • R² = \(String(format: "%.4f", result.rSquared))") - print(" • F-statistic p-value = \(String(format: "%.6f", result.fStatisticPValue))") - print(" • Advertising coefficient: \(String(format: "%.2f", result.coefficients[0]))") - print(" - Standard error: \(String(format: "%.2f", result.standardErrors[1]))") - print(" - p-value: \(String(format: "%.6f", result.pValues[1]))") - print(" - 95% CI: [\(String(format: "%.2f", result.confidenceIntervals[1].lower)), \(String(format: "%.2f", result.confidenceIntervals[1].upper))]\n") + print(" • R² = \(result.rSquared.number(4))") + print(" • F-statistic p-value = \(result.fStatisticPValue.number(6))") + print(" • Advertising coefficient: \(result.coefficients[0].number(2))") + print(" - Standard error: \(result.standardErrors[1].number(2))") + print(" - p-value: \(result.pValues[1].number(6))") + print(" - 95% CI: [\(result.confidenceIntervals[1].lower.number(2)), \(result.confidenceIntervals[1].upper.number(2))]\n") print("Interpretation:") print(" For every $1,000 increase in advertising spend,") - print(" sales increase by $\(String(format: "%.2f", result.coefficients[0] * 1000)).\n") + print(" sales increase by $\((result.coefficients[0] * 1000).number(2)).\n") } catch { print("Error: \(error)\n") } @@ -60,14 +60,14 @@ public func runLinearRegressionConvenienceExamples() { do { let result = try polynomialRegression(x: price, y: revenue, degree: 2) - print("Model: Revenue = \(String(format: "%.2f", result.intercept))") - print(" + \(String(format: "%.2f", result.coefficients[0]))×Price") - print(" + \(String(format: "%.2f", result.coefficients[1]))×Price²\n") + print("Model: Revenue = \(result.intercept.number(2))") + print(" + \(result.coefficients[0].number(2))×Price") + print(" + \(result.coefficients[1].number(2))×Price²\n") print("Diagnostics:") - print(" • R² = \(String(format: "%.4f", result.rSquared))") - print(" • Adjusted R² = \(String(format: "%.4f", result.adjustedRSquared))") - print(" • VIF: \(result.vif.map { String(format: "%.2f", $0) })\n") + print(" • R² = \(result.rSquared.number(4))") + print(" • Adjusted R² = \(result.adjustedRSquared.number(4))") + print(" • VIF: \(result.vif.map { $0.number(2) })\n") // Find optimal price (vertex of parabola) let a = result.coefficients[1] @@ -76,12 +76,12 @@ public func runLinearRegressionConvenienceExamples() { let maxRevenue = result.intercept + b * optimalPrice + a * optimalPrice * optimalPrice print("Optimal Pricing:") - print(" • Price: $\(String(format: "%.2f", optimalPrice))") - print(" • Maximum Revenue: $\(String(format: "%.2f", maxRevenue))K\n") + print(" • Price: $\(optimalPrice.number(2))") + print(" • Maximum Revenue: $\(maxRevenue.number(2))K\n") print("Interpretation:") print(" The quadratic model captures the inverted-U relationship.") - print(" Revenue increases with price up to $\(String(format: "%.2f", optimalPrice)), then decreases.\n") + print(" Revenue increases with price up to $\(optimalPrice.number(2)), then decreases.\n") } catch { print("Error: \(error)\n") } @@ -103,15 +103,15 @@ public func runLinearRegressionConvenienceExamples() { do { let result = try polynomialRegression(x: time, y: adoption, degree: 3) - print("Model: Adoption = \(String(format: "%.2f", result.intercept))") - print(" + \(String(format: "%.2f", result.coefficients[0]))×t") - print(" + \(String(format: "%.2f", result.coefficients[1]))×t²") - print(" + \(String(format: "%.2f", result.coefficients[2]))×t³\n") + print("Model: Adoption = \(result.intercept.number(2))") + print(" + \(result.coefficients[0].number(2))×t") + print(" + \(result.coefficients[1].number(2))×t²") + print(" + \(result.coefficients[2].number(2))×t³\n") print("Diagnostics:") - print(" • R² = \(String(format: "%.6f", result.rSquared))") + print(" • R² = \(result.rSquared.number(6))") print(" • All coefficients significant: \(result.pValues.allSatisfy { $0 < 0.05 } ? "✓" : "✗")") - print(" • VIF values: \(result.vif.map { String(format: "%.1f", $0) })") + print(" • VIF values: \(result.vif.map { $0.number(1) })") if result.vif.contains(where: { $0 > 10 }) { print(" ⚠️ High multicollinearity detected (VIF > 10)") @@ -143,13 +143,13 @@ public func runLinearRegressionConvenienceExamples() { let quadraticResult = try polynomialRegression(x: x, y: y, degree: 2) print("Linear Model:") - print(" • R² = \(String(format: "%.4f", linearResult.rSquared))") - print(" • Residual SE = \(String(format: "%.2f", linearResult.residualStandardError))\n") + print(" • R² = \(linearResult.rSquared.number(4))") + print(" • Residual SE = \(linearResult.residualStandardError.number(2))\n") print("Quadratic Model:") - print(" • R² = \(String(format: "%.6f", quadraticResult.rSquared))") - print(" • Adjusted R² = \(String(format: "%.6f", quadraticResult.adjustedRSquared))") - print(" • Residual SE = \(String(format: "%.6f", quadraticResult.residualStandardError))\n") + print(" • R² = \(quadraticResult.rSquared.number(6))") + print(" • Adjusted R² = \(quadraticResult.adjustedRSquared.number(6))") + print(" • Residual SE = \(quadraticResult.residualStandardError.number(6))\n") print("Conclusion:") print(" The quadratic model is superior (R² closer to 1, lower residual error).") diff --git a/Examples/MultipleLinearRegressionExample.swift b/Examples/MultipleLinearRegressionExample.swift index f2cb8f01..c1f91ec1 100644 --- a/Examples/MultipleLinearRegressionExample.swift +++ b/Examples/MultipleLinearRegressionExample.swift @@ -26,28 +26,22 @@ do { let result = try multipleLinearRegression(X: X1, y: sales) print("\nModel: Sales = β₀ + β₁ × Advertising") - print(String(format: " Sales = %.2f + %.2f × Advertising", - result.intercept, result.coefficients[0])) - print(String(format: "\nR² = %.4f (%.1f%% of variance explained)", - result.rSquared, result.rSquared * 100)) - print(String(format: "F-statistic = %.2f (p = %.6f)", - result.fStatistic, result.fStatisticPValue)) + print(" Sales = \(result.intercept.number(2)) + \(result.coefficients[0].number(2)) × Advertising") + print("\nR² = \(result.rSquared.number(4)) (\((result.rSquared * 100).number(1))% of variance explained)") + print("F-statistic = \(result.fStatistic.number(2)) (p = \(result.fStatisticPValue.number(6)))") if result.fStatisticPValue < 0.05 { print("✓ Model is statistically significant (p < 0.05)") } print("\nCoefficient Details:") - print(String(format: " Intercept: %.2f (SE = %.2f, p = %.4f)", - result.intercept, result.standardErrors[0], result.pValues[0])) - print(String(format: " Advertising: %.2f (SE = %.2f, p = %.4f)", - result.coefficients[0], result.standardErrors[1], result.pValues[1])) + print(" Intercept: \(result.intercept.number(2)) (SE = \(result.standardErrors[0].number(2)), p = \(result.pValues[0].number(4)))") + print(" Advertising: \(result.coefficients[0].number(2)) (SE = \(result.standardErrors[1].number(2)), p = \(result.pValues[1].number(4)))") // Make a prediction let newAdvertising = 55.0 let predictedSales = result.intercept + result.coefficients[0] * newAdvertising - print(String(format: "\nPrediction: $%.0fk advertising → $%.0fk sales", - newAdvertising, predictedSales)) + print("\nPrediction: $\(newAdvertising.number(0))k advertising → $\(predictedSales.number(0))k sales") } catch { print("Error: \(error)") @@ -70,22 +64,18 @@ do { let result = try multipleLinearRegression(X: X2, y: prices) print("\nModel: Price = β₀ + β₁×Size + β₂×Age") - print(String(format: " Price = %.2f + %.4f×Size + %.4f×Age", - result.intercept, result.coefficients[0], result.coefficients[1])) + print(" Price = \(result.intercept.number(2)) + \(result.coefficients[0].number(4))×Size + \(result.coefficients[1].number(4))×Age") - print(String(format: "\nModel Fit: R² = %.4f, Adjusted R² = %.4f", - result.rSquared, result.adjustedRSquared)) + print("\nModel Fit: R² = \(result.rSquared.number(4)), Adjusted R² = \(result.adjustedRSquared.number(4))") print("\nCoefficient Interpretations:") - print(String(format: " Size: $%.2f per sq ft (p = %.4f)", - result.coefficients[0], result.pValues[1])) + print(" Size: $\(result.coefficients[0].number(2)) per sq ft (p = \(result.pValues[1].number(4)))") if result.pValues[1] < 0.05 { print(" ✓ Size is a significant predictor") } - print(String(format: " Age: $%.2f per year (p = %.4f)", - result.coefficients[1], result.pValues[2])) + print(" Age: $\(result.coefficients[1].number(2)) per year (p = \(result.pValues[2].number(4)))") if result.pValues[2] < 0.05 { print(" ✓ Age is a significant predictor") @@ -96,16 +86,14 @@ do { let predictorNames = ["Size", "Age"] for (i, vif) in result.vif.enumerated() { let status = vif < 5 ? "✓ Low" : vif < 10 ? "⚠️ Moderate" : "✗ High" - print(String(format: " %@: VIF = %.2f (%@)", - predictorNames[i], vif, status)) + print(" \(predictorNames[i]): VIF = \(vif.number(2)) (\(status))") } // Confidence intervals print("\n95% Confidence Intervals:") for i in 0.. 10 }) { print("\n⚠️ SEVERE multicollinearity detected (VIF > 10)!") @@ -169,11 +156,8 @@ do { // Show unstable coefficients due to multicollinearity print("\nCoefficient Standard Errors:") for i in 0.. model1.adjustedRSquared { @@ -220,8 +204,7 @@ do { // Check if second predictor is significant if model2.pValues[2] > 0.05 { - print(String(format: " → Second predictor not significant (p = %.4f > 0.05)", - model2.pValues[2])) + print(" → Second predictor not significant (p = \(model2.pValues[2].number(4)) > 0.05)") } } catch { diff --git a/Sources/BusinessMath/Portfolio/PortfolioUtilities.swift b/Sources/BusinessMath/Portfolio/PortfolioUtilities.swift index 05e14372..c8b78d8b 100644 --- a/Sources/BusinessMath/Portfolio/PortfolioUtilities.swift +++ b/Sources/BusinessMath/Portfolio/PortfolioUtilities.swift @@ -12,8 +12,15 @@ import Numerics /// Generates a vector of random expected returns. /// -/// Creates asset returns from a normal distribution with specified mean and standard deviation. -/// Useful for portfolio optimization examples and Monte Carlo simulations. +/// Creates asset returns from a normal distribution with specified mean and +/// standard deviation. Useful for portfolio optimization examples and Monte +/// Carlo simulations. +/// +/// This overload uses the system random number generator. Tests and any +/// other code that requires reproducibility should use the seeded overload +/// `generateRandomReturns(count:mean:stdDev:using:)` and supply an explicit +/// `RandomNumberGenerator` (e.g., a `SystemRandomNumberGenerator` for +/// production or a deterministic generator for tests). /// /// - Parameters: /// - count: Number of assets @@ -31,12 +38,45 @@ public func generateRandomReturns( count: Int, mean: Double, stdDev: Double +) -> VectorN { + var generator = SystemRandomNumberGenerator() + return generateRandomReturns(count: count, mean: mean, stdDev: stdDev, using: &generator) +} + +/// Generates a vector of random expected returns using a caller-supplied +/// random number generator (additive overload, v2.1.4). +/// +/// Use this overload when you need reproducibility — pass a deterministic +/// generator like `TestSupport.SeededRNG` (in tests) or any conforming +/// `RandomNumberGenerator`. The generated values are normally distributed +/// via the Box–Muller transform. +/// +/// - Parameters: +/// - count: Number of assets +/// - mean: Expected mean return +/// - stdDev: Standard deviation of returns +/// - generator: Random number generator to draw from +/// - Returns: Vector of expected returns +/// +/// ## Example +/// ```swift +/// // Reproducible: use a seeded generator +/// var rng = SystemRandomNumberGenerator() +/// let returns = generateRandomReturns(count: 100, mean: 0.10, stdDev: 0.05, using: &rng) +/// ``` +public func generateRandomReturns( + count: Int, + mean: Double, + stdDev: Double, + using generator: inout G ) -> VectorN { let returns = (0.. Double { - state = state &* 6364136223846793005 &+ 1 - let upper = Double((state >> 32) & 0xFFFFFFFF) - return upper / Double(UInt32.max) - } - } - - var rng = SeededRNG(state: 54321) + // Seeded RNG for deterministic test (MMIX LCG via TestSupport) + var rng = MMIXSeededRNG(state: 54321) var array: [Double] = [] // Increased sample size for better statistical properties for _ in 0..<10000 { diff --git a/Tests/BusinessMathTests/Statistics Tests/Descriptor Tests/Dispersion Around the Mean Tests/Dispersion Around the Mean Tests.swift b/Tests/BusinessMathTests/Statistics Tests/Descriptor Tests/Dispersion Around the Mean Tests/Dispersion Around the Mean Tests.swift index b11ca404..6570e2c3 100644 --- a/Tests/BusinessMathTests/Statistics Tests/Descriptor Tests/Dispersion Around the Mean Tests/Dispersion Around the Mean Tests.swift +++ b/Tests/BusinessMathTests/Statistics Tests/Descriptor Tests/Dispersion Around the Mean Tests/Dispersion Around the Mean Tests.swift @@ -126,17 +126,8 @@ import Glibc let sampleCount = 10000 let theoreticalVariance20 = Double(df20) / Double(df20 - 2) // 20/18 ≈ 1.1111 - // Helper to generate deterministic seeds (similar to StudentTDistributionTests) - struct SeededRNG { - var state: UInt64 - mutating func next() -> Double { - state = state &* 6364136223846793005 &+ 1 - let upper = Double((state >> 32) & 0xFFFFFFFF) - return upper / Double(UInt32.max) - } - } - - var rng = SeededRNG(state: 12345) + // Deterministic seeds via TestSupport's consolidated MMIX generator + var rng = MMIXSeededRNG(state: 12345) var samples: [Double] = [] for _ in 0.. Double { - state = state &* 6364136223846793005 &+ 1 - let upper = Double((state >> 32) & 0xFFFFFFFF) - return upper / Double(UInt32.max) - } - } - - // Create seeded random number generator for reproducibility - var rng = SeededRNG(state: 42) + // Seeded RNG via TestSupport's consolidated MMIX generator + var rng = MMIXSeededRNG(state: 42) // Generate deterministic data points with minimal noise var data: [ReciprocalRegressionModel.DataPoint] = [] diff --git a/Tests/BusinessMathTests/Statistics Tests/Regression Tests/DenseMatrixTests.swift b/Tests/BusinessMathTests/Statistics Tests/Regression Tests/DenseMatrixTests.swift index f15fe8fe..f7a8b475 100644 --- a/Tests/BusinessMathTests/Statistics Tests/Regression Tests/DenseMatrixTests.swift +++ b/Tests/BusinessMathTests/Statistics Tests/Regression Tests/DenseMatrixTests.swift @@ -6,6 +6,7 @@ // import Testing +import TestSupport // MMIXSeededRNG import Numerics @testable import BusinessMath @@ -439,24 +440,18 @@ struct DenseMatrixTests { @Test("Large matrix-vector multiplication", .timeLimit(.minutes(1))) func largeMatrixVectorMultiplication() throws { - // Seeded RNG for deterministic test - struct SeededRNG { - var state: UInt64 - mutating func next() -> Double { - state = state &* 6364136223846793005 &+ 1 - let upper = Double((state >> 32) & 0xFFFFFFFF) - return (upper / Double(UInt32.max)) * 2.0 - 1.0 // Map to [-1, 1] - } - } - - var rng = SeededRNG(state: 12345) + // Seeded RNG via TestSupport. The DenseMatrix tests previously used + // a local SeededRNG that mapped output to [-1, 1] directly from + // next(); MMIXSeededRNG.nextSigned() does the same mapping with + // the bit-identical underlying LCG sequence. + var rng = MMIXSeededRNG(state: 12345) let size = 1000 let data = (0.. Double { - state = state &* 6364136223846793005 &+ 1 - let upper = Double((state >> 32) & 0xFFFFFFFF) - return upper / Double(UInt32.max) - } - } - - var rng = SeededRNG(state: 42) + // Seeded RNG via TestSupport's consolidated MMIX generator + var rng = MMIXSeededRNG(state: 42) let x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] var y: [Double] = [] diff --git a/Tests/TestSupport/SeededRNG.swift b/Tests/TestSupport/SeededRNG.swift index b9f527e4..eb13a9ad 100644 --- a/Tests/TestSupport/SeededRNG.swift +++ b/Tests/TestSupport/SeededRNG.swift @@ -2,7 +2,7 @@ // SeededRNG.swift // TestSupport // -// Deterministic pseudo-random number generator for reproducible tests +// Deterministic pseudo-random number generators for reproducible tests // import Foundation @@ -39,3 +39,49 @@ public class SeededRNG { self.state = seed } } + +/// Knuth's MMIX-style Linear Congruential Generator (value-type variant). +/// +/// Uses the LCG multiplier `6364136223846793005` from Knuth's MMIX +/// (TAOCP §3.3.4, Table 1, Line 26). The output is the upper 32 bits of +/// the state divided by `UInt32.max`, producing values in `[0, 1]`. +/// +/// **Why this exists alongside the LCG-based ``SeededRNG``:** several +/// existing test files in BusinessMath had inlined this exact LCG variant +/// as a local `struct SeededRNG { var state: UInt64; ... }`. Consolidating +/// them under a shared type required preserving the bit-exact output +/// sequence so that test assertions wouldn't need re-tuning. This type +/// matches that local sequence exactly. +/// +/// `MMIXSeededRNG` is a value type (struct) with `mutating func next()`, +/// matching the API the migrated test files were already using. For new +/// tests, prefer `SeededRNG` unless you have a specific reason to need +/// the MMIX sequence. +public struct MMIXSeededRNG { + /// Internal LCG state. Public so tests can save and restore positions + /// if needed. + public var state: UInt64 + + /// Create a generator seeded with the given state. + public init(state: UInt64 = 12345) { + self.state = state + } + + /// Generate the next random `Double` in `[0, 1]`. + /// + /// Mutates `state` in place using the MMIX LCG formula: + /// state = state * 6364136223846793005 + 1 + public mutating func next() -> Double { + state = state &* 6364136223846793005 &+ 1 + let upper = Double((state >> 32) & 0xFFFFFFFF) + return upper / Double(UInt32.max) + } + + /// Generate the next random `Double` mapped to `[-1, 1]`. + /// + /// Convenience for tests that need signed random values without + /// re-implementing the mapping. Equivalent to `next() * 2 - 1`. + public mutating func nextSigned() -> Double { + return next() * 2.0 - 1.0 + } +}