philiprehberger-math_kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b0ee57bd765f6a85f692da57097963ad6a2b24ccb6d3aa72a933f116faf9d7ec
4
+ data.tar.gz: a07eca98e2878406fb271187fd77e3d5628153ae0e19d74063998510478b631d
5
+ SHA512:
6
+ metadata.gz: 1c202b40a1dfbd0282d15573433558b0fdacc071a4f33fe7c3cf1547415a3e93a0aa12cd218573069109c659921a97690d3b2a9f8c0b895f573a60c6f651a72e
7
+ data.tar.gz: 9ce0bf2fa3522a4a9cc922a564ac8102a1b5327e876ed223a43374514809a97311295ed8e5a69dd530ba9634571fbb07ce19b0233ba2067b61d88fbb6a353af1
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-03-26
11
+
12
+ ### Added
13
+ - Initial release
14
+ - Descriptive statistics: mean, median, mode, variance, stddev, percentile, sum, range
15
+ - Linear interpolation between sorted points with extrapolation
16
+ - Rounding modes: bankers (round half to even), ceiling, floor, truncate with precision
17
+ - Simple moving average and exponential moving average
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 philiprehberger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # philiprehberger-math_kit
2
+
3
+ [![Tests](https://github.com/philiprehberger/rb-math-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-math-kit/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/philiprehberger-math_kit.svg)](https://rubygems.org/gems/philiprehberger-math_kit)
5
+ [![License](https://img.shields.io/github/license/philiprehberger/rb-math-kit)](LICENSE)
6
+ [![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ec6cb9)](https://github.com/sponsors/philiprehberger)
7
+
8
+ Common math and statistics utilities for Ruby. Descriptive statistics, linear interpolation, rounding modes, and moving averages with zero dependencies.
9
+
10
+ ## Requirements
11
+
12
+ - Ruby >= 3.1
13
+
14
+ ## Installation
15
+
16
+ Add to your Gemfile:
17
+
18
+ ```ruby
19
+ gem "philiprehberger-math_kit"
20
+ ```
21
+
22
+ Or install directly:
23
+
24
+ ```bash
25
+ gem install philiprehberger-math_kit
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```ruby
31
+ require "philiprehberger/math_kit"
32
+ ```
33
+
34
+ ### Statistics
35
+
36
+ ```ruby
37
+ Philiprehberger::MathKit::Stats.mean([1, 2, 3, 4, 5]) # => 3.0
38
+ Philiprehberger::MathKit::Stats.median([3, 1, 4, 1, 5]) # => 3.0
39
+ Philiprehberger::MathKit::Stats.mode([1, 2, 2, 3]) # => [2]
40
+ Philiprehberger::MathKit::Stats.variance([2, 4, 4, 4, 5, 5, 7, 9]) # => 4.0
41
+ Philiprehberger::MathKit::Stats.stddev([2, 4, 4, 4, 5, 5, 7, 9]) # => 2.0
42
+ Philiprehberger::MathKit::Stats.percentile([1, 2, 3, 4, 5], 50) # => 3.0
43
+ Philiprehberger::MathKit::Stats.sum([1, 2, 3]) # => 6
44
+ Philiprehberger::MathKit::Stats.range([1, 5, 3, 9, 2]) # => 8
45
+ ```
46
+
47
+ Sample variance and standard deviation:
48
+
49
+ ```ruby
50
+ Philiprehberger::MathKit::Stats.variance([2, 4, 4, 4, 5, 5, 7, 9], population: false) # => 4.571...
51
+ Philiprehberger::MathKit::Stats.stddev([2, 4, 4, 4, 5, 5, 7, 9], population: false) # => 2.138...
52
+ ```
53
+
54
+ ### Interpolation
55
+
56
+ ```ruby
57
+ points = [[0, 0], [5, 10], [10, 20]]
58
+ Philiprehberger::MathKit::Interpolation.linear(points, 2.5) # => 5.0
59
+ Philiprehberger::MathKit::Interpolation.linear(points, 7.5) # => 15.0
60
+ ```
61
+
62
+ ### Rounding
63
+
64
+ ```ruby
65
+ Philiprehberger::MathKit::Round.bankers(2.5) # => 2.0 (round half to even)
66
+ Philiprehberger::MathKit::Round.bankers(3.5) # => 4.0
67
+ Philiprehberger::MathKit::Round.ceiling(2.1) # => 3.0
68
+ Philiprehberger::MathKit::Round.floor(2.9) # => 2.0
69
+ Philiprehberger::MathKit::Round.truncate(2.9) # => 2.0
70
+ Philiprehberger::MathKit::Round.truncate(-2.9) # => -2.0
71
+ Philiprehberger::MathKit::Round.bankers(2.55, precision: 1) # => 2.6
72
+ ```
73
+
74
+ ### Moving Averages
75
+
76
+ ```ruby
77
+ Philiprehberger::MathKit::MovingAverage.simple([1, 2, 3, 4, 5], window: 3) # => [2.0, 3.0, 4.0]
78
+ Philiprehberger::MathKit::MovingAverage.exponential([1, 2, 3, 4, 5], alpha: 0.5) # => [1.0, 1.5, 2.25, 3.125, 4.0625]
79
+ ```
80
+
81
+ ## API
82
+
83
+ | Method | Description |
84
+ |--------|-------------|
85
+ | `Stats.mean(values)` | Arithmetic mean |
86
+ | `Stats.median(values)` | Median (middle value or average of two middle) |
87
+ | `Stats.mode(values)` | Mode(s) as array |
88
+ | `Stats.variance(values, population: true)` | Population or sample variance |
89
+ | `Stats.stddev(values, population: true)` | Standard deviation |
90
+ | `Stats.percentile(values, p)` | Percentile (0-100) |
91
+ | `Stats.sum(values)` | Sum of values |
92
+ | `Stats.range(values)` | Max - min |
93
+ | `Interpolation.linear(points, x)` | Linear interpolation between points |
94
+ | `Round.bankers(value, precision: 0)` | Banker's rounding (round half to even) |
95
+ | `Round.ceiling(value, precision: 0)` | Round up |
96
+ | `Round.floor(value, precision: 0)` | Round down |
97
+ | `Round.truncate(value, precision: 0)` | Truncate toward zero |
98
+ | `MovingAverage.simple(values, window:)` | Simple moving average |
99
+ | `MovingAverage.exponential(values, alpha:)` | Exponential moving average |
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ bundle install
105
+ bundle exec rspec # Run tests
106
+ bundle exec rubocop # Check code style
107
+ ```
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module MathKit
5
+ module Interpolation
6
+ class << self
7
+ # Linear interpolation between sorted points
8
+ #
9
+ # @param points [Array<Array(Numeric, Numeric)>] sorted array of [x, y] pairs
10
+ # @param x [Numeric] the x value to interpolate at
11
+ # @return [Float] the interpolated y value
12
+ # @raise [ArgumentError] if fewer than 2 points or x is out of range
13
+ def linear(points, x)
14
+ raise ArgumentError, 'at least 2 points are required' if points.size < 2
15
+
16
+ sorted = points.sort_by(&:first)
17
+
18
+ if x <= sorted.first[0]
19
+ return extrapolate(sorted[0], sorted[1], x) if x < sorted.first[0]
20
+
21
+ return sorted.first[1].to_f
22
+ end
23
+
24
+ if x >= sorted.last[0]
25
+ return extrapolate(sorted[-2], sorted[-1], x) if x > sorted.last[0]
26
+
27
+ return sorted.last[1].to_f
28
+ end
29
+
30
+ # Find the bracketing pair
31
+ i = sorted.index { |pt| pt[0] >= x }
32
+ return sorted[i][1].to_f if sorted[i][0] == x
33
+
34
+ interpolate_between(sorted[i - 1], sorted[i], x)
35
+ end
36
+
37
+ private
38
+
39
+ def interpolate_between(p1, p2, x)
40
+ x1, y1 = p1
41
+ x2, y2 = p2
42
+ t = (x - x1).to_f / (x2 - x1)
43
+ (y1 + (t * (y2 - y1))).to_f
44
+ end
45
+
46
+ def extrapolate(p1, p2, x)
47
+ interpolate_between(p1, p2, x)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module MathKit
5
+ module MovingAverage
6
+ class << self
7
+ # Simple moving average
8
+ #
9
+ # @param values [Array<Numeric>] the input values
10
+ # @param window [Integer] the window size
11
+ # @return [Array<Float>] the SMA values (size = values.size - window + 1)
12
+ # @raise [ArgumentError] if window is larger than values or less than 1
13
+ def simple(values, window:)
14
+ raise ArgumentError, 'window must be at least 1' if window < 1
15
+ raise ArgumentError, 'window must not exceed values size' if window > values.size
16
+
17
+ (0..(values.size - window)).map do |i|
18
+ values[i, window].sum.to_f / window
19
+ end
20
+ end
21
+
22
+ # Exponential moving average
23
+ #
24
+ # @param values [Array<Numeric>] the input values
25
+ # @param alpha [Float] the smoothing factor (0 < alpha <= 1)
26
+ # @return [Array<Float>] the EMA values (same size as input)
27
+ # @raise [ArgumentError] if alpha is out of range or values is empty
28
+ def exponential(values, alpha:)
29
+ raise ArgumentError, 'values must not be empty' if values.empty?
30
+ raise ArgumentError, 'alpha must be between 0 (exclusive) and 1 (inclusive)' if alpha <= 0 || alpha > 1
31
+
32
+ result = [values.first.to_f]
33
+ values[1..].each do |v|
34
+ result << ((alpha * v) + ((1 - alpha) * result.last))
35
+ end
36
+ result
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module MathKit
5
+ module Round
6
+ class << self
7
+ # Banker's rounding (round half to even)
8
+ #
9
+ # @param value [Numeric] the value to round
10
+ # @param precision [Integer] number of decimal places
11
+ # @return [Float] the rounded value
12
+ def bankers(value, precision: 0)
13
+ multiplier = 10**precision
14
+ scaled = value * multiplier
15
+
16
+ # Check if exactly at the halfway point
17
+ if halfway?(scaled)
18
+ floored = scaled.floor
19
+ # Round to even
20
+ if floored.even?
21
+ floored.to_f / multiplier
22
+ else
23
+ (floored + 1).to_f / multiplier
24
+ end
25
+ else
26
+ scaled.round.to_f / multiplier
27
+ end
28
+ end
29
+
30
+ # Round up (ceiling)
31
+ #
32
+ # @param value [Numeric] the value to round
33
+ # @param precision [Integer] number of decimal places
34
+ # @return [Float] the rounded value
35
+ def ceiling(value, precision: 0)
36
+ multiplier = 10**precision
37
+ (value * multiplier).ceil.to_f / multiplier
38
+ end
39
+
40
+ # Round down (floor)
41
+ #
42
+ # @param value [Numeric] the value to round
43
+ # @param precision [Integer] number of decimal places
44
+ # @return [Float] the rounded value
45
+ def floor(value, precision: 0)
46
+ multiplier = 10**precision
47
+ (value * multiplier).floor.to_f / multiplier
48
+ end
49
+
50
+ # Truncate toward zero
51
+ #
52
+ # @param value [Numeric] the value to truncate
53
+ # @param precision [Integer] number of decimal places
54
+ # @return [Float] the truncated value
55
+ def truncate(value, precision: 0)
56
+ multiplier = 10**precision
57
+ (value * multiplier).truncate.to_f / multiplier
58
+ end
59
+
60
+ private
61
+
62
+ def halfway?(scaled)
63
+ fractional = (scaled - scaled.floor).abs
64
+ (fractional - 0.5).abs < 1e-9
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module MathKit
5
+ module Stats
6
+ class << self
7
+ # Arithmetic mean of values
8
+ #
9
+ # @param values [Array<Numeric>] the input values
10
+ # @return [Float] the arithmetic mean
11
+ # @raise [ArgumentError] if values is empty
12
+ def mean(values)
13
+ raise ArgumentError, 'values must not be empty' if values.empty?
14
+
15
+ sum(values).to_f / values.size
16
+ end
17
+
18
+ # Median (middle value or average of two middle values)
19
+ #
20
+ # @param values [Array<Numeric>] the input values
21
+ # @return [Float] the median
22
+ # @raise [ArgumentError] if values is empty
23
+ def median(values)
24
+ raise ArgumentError, 'values must not be empty' if values.empty?
25
+
26
+ sorted = values.sort
27
+ mid = sorted.size / 2
28
+
29
+ if sorted.size.odd?
30
+ sorted[mid].to_f
31
+ else
32
+ (sorted[mid - 1] + sorted[mid]).to_f / 2
33
+ end
34
+ end
35
+
36
+ # Mode(s) — most frequently occurring value(s)
37
+ #
38
+ # @param values [Array<Numeric>] the input values
39
+ # @return [Array<Numeric>] the mode(s) as an array
40
+ # @raise [ArgumentError] if values is empty
41
+ def mode(values)
42
+ raise ArgumentError, 'values must not be empty' if values.empty?
43
+
44
+ freq = values.tally
45
+ max_count = freq.values.max
46
+ freq.select { |_, count| count == max_count }.keys
47
+ end
48
+
49
+ # Population or sample variance
50
+ #
51
+ # @param values [Array<Numeric>] the input values
52
+ # @param population [Boolean] true for population variance, false for sample
53
+ # @return [Float] the variance
54
+ # @raise [ArgumentError] if values is empty or sample variance with fewer than 2 values
55
+ def variance(values, population: true)
56
+ raise ArgumentError, 'values must not be empty' if values.empty?
57
+
58
+ n = values.size
59
+ raise ArgumentError, 'sample variance requires at least 2 values' if !population && n < 2
60
+
61
+ avg = mean(values)
62
+ sum_sq = values.sum { |v| (v - avg)**2 }
63
+ divisor = population ? n : n - 1
64
+ sum_sq.to_f / divisor
65
+ end
66
+
67
+ # Standard deviation
68
+ #
69
+ # @param values [Array<Numeric>] the input values
70
+ # @param population [Boolean] true for population stddev, false for sample
71
+ # @return [Float] the standard deviation
72
+ def stddev(values, population: true)
73
+ Math.sqrt(variance(values, population: population))
74
+ end
75
+
76
+ # Percentile (0-100) using linear interpolation
77
+ #
78
+ # @param values [Array<Numeric>] the input values
79
+ # @param p [Numeric] the percentile (0-100)
80
+ # @return [Float] the percentile value
81
+ # @raise [ArgumentError] if values is empty or p is out of range
82
+ def percentile(values, p)
83
+ raise ArgumentError, 'values must not be empty' if values.empty?
84
+ raise ArgumentError, 'percentile must be between 0 and 100' if p.negative? || p > 100
85
+
86
+ sorted = values.sort
87
+ return sorted.first.to_f if p.zero?
88
+ return sorted.last.to_f if p == 100
89
+
90
+ rank = (p / 100.0) * (sorted.size - 1)
91
+ lower = rank.floor
92
+ upper = rank.ceil
93
+
94
+ return sorted[lower].to_f if lower == upper
95
+
96
+ fraction = rank - lower
97
+ (sorted[lower] + (fraction * (sorted[upper] - sorted[lower]))).to_f
98
+ end
99
+
100
+ # Sum of values
101
+ #
102
+ # @param values [Array<Numeric>] the input values
103
+ # @return [Numeric] the sum
104
+ def sum(values)
105
+ values.sum
106
+ end
107
+
108
+ # Range (max - min)
109
+ #
110
+ # @param values [Array<Numeric>] the input values
111
+ # @return [Numeric] the range
112
+ # @raise [ArgumentError] if values is empty
113
+ def range(values)
114
+ raise ArgumentError, 'values must not be empty' if values.empty?
115
+
116
+ values.max - values.min
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module MathKit
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'math_kit/version'
4
+ require_relative 'math_kit/stats'
5
+ require_relative 'math_kit/interpolation'
6
+ require_relative 'math_kit/round'
7
+ require_relative 'math_kit/moving_average'
8
+
9
+ module Philiprehberger
10
+ module MathKit
11
+ class Error < StandardError; end
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: philiprehberger-math_kit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Philip Rehberger
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-27 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Descriptive statistics, linear interpolation, rounding modes, and moving
14
+ averages. Lightweight math toolkit with zero dependencies.
15
+ email:
16
+ - me@philiprehberger.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - lib/philiprehberger/math_kit.rb
25
+ - lib/philiprehberger/math_kit/interpolation.rb
26
+ - lib/philiprehberger/math_kit/moving_average.rb
27
+ - lib/philiprehberger/math_kit/round.rb
28
+ - lib/philiprehberger/math_kit/stats.rb
29
+ - lib/philiprehberger/math_kit/version.rb
30
+ homepage: https://github.com/philiprehberger/rb-math-kit
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ homepage_uri: https://github.com/philiprehberger/rb-math-kit
35
+ source_code_uri: https://github.com/philiprehberger/rb-math-kit
36
+ changelog_uri: https://github.com/philiprehberger/rb-math-kit/blob/main/CHANGELOG.md
37
+ bug_tracker_uri: https://github.com/philiprehberger/rb-math-kit/issues
38
+ rubygems_mfa_required: 'true'
39
+ post_install_message:
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 3.1.0
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 3.5.22
55
+ signing_key:
56
+ specification_version: 4
57
+ summary: Common math and statistics utilities for Ruby
58
+ test_files: []