Module: Philiprehberger::Approx

Defined in:
lib/philiprehberger/approx.rb,
lib/philiprehberger/approx/version.rb,
lib/philiprehberger/approx/comparator.rb,
lib/philiprehberger/approx/rspec_matchers.rb

Defined Under Namespace

Modules: RSpecMatchers Classes: Comparator, Error

Constant Summary collapse

VERSION =
'0.11.0'

Class Method Summary collapse

Class Method Details

.all_equal?(values, epsilon: nil, rel_tol: nil) ⇒ Boolean

Check if every element of an enumerable is approximately equal to the first

Accepts any Enumerable and iterates via .to_a. Empty and single-element collections return true. Reuses the same tolerance semantics as .equal?, so either absolute epsilon or relative tolerance (or both) may be supplied.

Parameters:

  • values (Enumerable)

    collection of values to compare

  • epsilon (Float) (defaults to: nil)

    maximum allowed absolute difference (defaults to .equal?‘s default)

  • rel_tol (Float) (defaults to: nil)

    relative tolerance (defaults to .equal?‘s default)

Returns:

  • (Boolean)

    true if every element is approximately equal to the first



47
48
49
50
51
52
53
54
55
56
57
# File 'lib/philiprehberger/approx.rb', line 47

def self.all_equal?(values, epsilon: nil, rel_tol: nil)
  arr = values.to_a
  return true if arr.length < 2

  opts = {}
  opts[:epsilon] = epsilon unless epsilon.nil?
  opts[:rel_tol] = rel_tol unless rel_tol.nil?

  first = arr.first
  arr.drop(1).all? { |v| equal?(first, v, **opts) }
end

.assert_near(a, b, epsilon: 1e-9, rel_tol: 0) ⇒ Object

Assert that two values are approximately equal, raising on mismatch

Parameters:

  • a (Numeric, Array, Hash)

    first value

  • b (Numeric, Array, Hash)

    second value

  • epsilon (Float) (defaults to: 1e-9)

    maximum allowed absolute difference

  • rel_tol (Float) (defaults to: 0)

    relative tolerance (default 0 — disabled)

Raises:

  • (Error)

    if values differ by more than the allowed tolerance



109
110
111
112
113
# File 'lib/philiprehberger/approx.rb', line 109

def self.assert_near(a, b, epsilon: 1e-9, rel_tol: 0)
  return if equal?(a, b, epsilon: epsilon, rel_tol: rel_tol)

  raise Error, "expected #{a.inspect} to be near #{b.inspect} (epsilon: #{epsilon}, rel_tol: #{rel_tol})"
end

.assert_within(a, b, abs: nil, rel: nil) ⇒ Object

Assert that two values pass within?, raising on mismatch

At least one of abs: or rel: must be provided.

Parameters:

  • a (Numeric, Array, Hash)

    first value

  • b (Numeric, Array, Hash)

    second value

  • abs (Float, nil) (defaults to: nil)

    absolute tolerance

  • rel (Float, nil) (defaults to: nil)

    relative tolerance

Raises:

  • (Error)

    if values fail both tolerance checks



171
172
173
174
175
# File 'lib/philiprehberger/approx.rb', line 171

def self.assert_within(a, b, abs: nil, rel: nil)
  return if within?(a, b, abs: abs, rel: rel)

  raise Error, "expected #{a.inspect} to be within #{b.inspect} (abs: #{abs}, rel: #{rel})"
end

.between?(value, min, max, epsilon: 1e-9) ⇒ Boolean

Check if a numeric value lies within [min, max] with epsilon slack on both ends

Parameters:

  • value (Numeric)

    value to test

  • min (Numeric)

    inclusive lower bound

  • max (Numeric)

    inclusive upper bound

  • epsilon (Float) (defaults to: 1e-9)

    tolerance applied to each bound

Returns:

  • (Boolean)

    true if min - epsilon <= value <= max + epsilon



131
132
133
# File 'lib/philiprehberger/approx.rb', line 131

def self.between?(value, min, max, epsilon: 1e-9)
  value.between?(min - epsilon, max + epsilon)
end

.clamp(value, target, epsilon: 1e-9, rel_tol: 0) ⇒ Numeric

Snap a value to target if approximately equal, otherwise return unchanged

Returns target when value is within epsilon of target. Useful for snapping near-values to an exact canonical value.

Parameters:

  • value (Numeric)

    the value to potentially snap

  • target (Numeric)

    the target to snap to

  • epsilon (Float) (defaults to: 1e-9)

    maximum allowed absolute difference

  • rel_tol (Float) (defaults to: 0)

    relative tolerance (default 0 — disabled)

Returns:

  • (Numeric)

    target if approximately equal, otherwise value



98
99
100
# File 'lib/philiprehberger/approx.rb', line 98

def self.clamp(value, target, epsilon: 1e-9, rel_tol: 0)
  equal?(value, target, epsilon: epsilon, rel_tol: rel_tol) ? target : value
end

.diff(a, b, epsilon: Float::EPSILON) ⇒ Hash

Return a diagnostic hash showing why values do or do not match

Parameters:

  • a (Numeric)

    first value

  • b (Numeric)

    second value

  • epsilon (Float) (defaults to: Float::EPSILON)

    maximum allowed difference

Returns:

  • (Hash)

    diagnostic hash with :match, :actual_diff, :allowed, :ratio



237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/philiprehberger/approx.rb', line 237

def self.diff(a, b, epsilon: Float::EPSILON)
  actual_diff = (a - b).abs.to_f
  allowed = epsilon.to_f
  ratio = allowed.zero? ? Float::INFINITY : actual_diff / allowed

  {
    match: actual_diff <= allowed,
    actual_diff: actual_diff,
    allowed: allowed,
    ratio: ratio
  }
end

.equal?(a, b, epsilon: 1e-9, rel_tol: 0) ⇒ Boolean

Check if two values are approximately equal within epsilon

When rel_tol is non-zero, values match if either tolerance passes, matching Python’s math.isclose semantics: |a - b| <= max(rel_tol * max(|a|, |b|), epsilon)

Parameters:

  • a (Numeric, Array, Hash)

    first value

  • b (Numeric, Array, Hash)

    second value

  • epsilon (Float) (defaults to: 1e-9)

    maximum allowed absolute difference

  • rel_tol (Float) (defaults to: 0)

    relative tolerance (default 0 — disabled)

Returns:

  • (Boolean)

    true if values are approximately equal



22
23
24
# File 'lib/philiprehberger/approx.rb', line 22

def self.equal?(a, b, epsilon: 1e-9, rel_tol: 0)
  compare(a, b, epsilon, rel_tol)
end

.max_diff(a, b, epsilon: 1e-10) ⇒ Hash?

Find the element pair with the largest absolute difference across two arrays or hashes

For arrays, iterates element-by-element and returns the index and values of the pair with the greatest absolute difference. For hashes, iterates over shared keys and uses :key instead of :index. Returns nil if both collections are empty. Raises Approx::Error for mismatched types or non-collection inputs.

Parameters:

  • a (Array, Hash)

    first collection

  • b (Array, Hash)

    second collection

  • epsilon (Float) (defaults to: 1e-10)

    tolerance used to set :match on the returned hash

Returns:

  • (Hash, nil)

    hash with :index/:key, :a, :b, :diff, :match, :epsilon or nil

Raises:

  • (Error)

    if inputs are not both arrays or both hashes



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/philiprehberger/approx.rb', line 199

def self.max_diff(a, b, epsilon: 1e-10)
  unless (a.is_a?(Array) && b.is_a?(Array)) || (a.is_a?(Hash) && b.is_a?(Hash))
    raise Error, 'both arguments must be arrays or both must be hashes'
  end

  if a.is_a?(Array)
    flat_a = a.flatten
    flat_b = b.flatten
    return nil if flat_a.empty? && flat_b.empty?

    best = nil
    flat_a.each_with_index do |val_a, i|
      val_b = flat_b[i]
      d = (val_a - val_b).abs
      best = { index: i, a: val_a, b: val_b, diff: d } if best.nil? || d > best[:diff]
    end
    best.merge(match: best[:diff] <= epsilon, epsilon: epsilon)
  else
    shared_keys = a.keys & b.keys
    return nil if shared_keys.empty?

    best = nil
    shared_keys.each do |k|
      val_a = a[k]
      val_b = b[k]
      d = (val_a - val_b).abs
      best = { key: k, a: val_a, b: val_b, diff: d } if best.nil? || d > best[:diff]
    end
    best.merge(match: best[:diff] <= epsilon, epsilon: epsilon)
  end
end

.near?(a, b, epsilon: 1e-9, rel_tol: 0) ⇒ Boolean

Alias for equal? with explicit epsilon

Parameters:

  • a (Numeric, Array, Hash)

    first value

  • b (Numeric, Array, Hash)

    second value

  • epsilon (Float) (defaults to: 1e-9)

    maximum allowed absolute difference

  • rel_tol (Float) (defaults to: 0)

    relative tolerance (default 0 — disabled)

Returns:

  • (Boolean)

    true if values are near each other



33
34
35
# File 'lib/philiprehberger/approx.rb', line 33

def self.near?(a, b, epsilon: 1e-9, rel_tol: 0)
  equal?(a, b, epsilon: epsilon, rel_tol: rel_tol)
end

.percent_equal?(a, b, percent:) ⇒ Boolean

Check if two values are approximately equal within a percentage tolerance

Parameters:

  • a (Numeric, Array, Hash)

    first value

  • b (Numeric, Array, Hash)

    second value

  • percent (Float)

    maximum allowed percentage difference

Returns:

  • (Boolean)

    true if values are within the percentage tolerance



183
184
185
# File 'lib/philiprehberger/approx.rb', line 183

def self.percent_equal?(a, b, percent:)
  compare_percent(a, b, percent)
end

.relative_equal?(a, b, tolerance: 1e-6) ⇒ Boolean

Check if two values are approximately equal using relative tolerance

Relative tolerance: |a - b| / max(|a|, |b|) <= tolerance Falls back to absolute comparison when both values are zero.

Parameters:

  • a (Numeric, Array, Hash)

    first value

  • b (Numeric, Array, Hash)

    second value

  • tolerance (Float) (defaults to: 1e-6)

    maximum allowed relative difference

Returns:

  • (Boolean)

    true if values are relatively near each other



68
69
70
# File 'lib/philiprehberger/approx.rb', line 68

def self.relative_equal?(a, b, tolerance: 1e-6)
  compare_relative(a, b, tolerance)
end

.sign_equal?(a, b, epsilon: 1e-9) ⇒ Boolean

Check if two numeric values share the same sign

Values with |x| <= epsilon are treated as zero, so two near-zero values are considered to share a sign regardless of their raw polarity.

Parameters:

  • a (Numeric)

    first value

  • b (Numeric)

    second value

  • epsilon (Float) (defaults to: 1e-9)

    threshold below which a value is treated as zero

Returns:

  • (Boolean)

    true if both values share the same sign (or both are near zero)



153
154
155
156
157
158
159
160
# File 'lib/philiprehberger/approx.rb', line 153

def self.sign_equal?(a, b, epsilon: 1e-9)
  a_zero = a.abs <= epsilon
  b_zero = b.abs <= epsilon
  return true if a_zero && b_zero
  return false if a_zero || b_zero

  (a.positive? && b.positive?) || (a.negative? && b.negative?)
end

.tolerance_range(value, epsilon: 1e-9) ⇒ Array<Numeric>

Return the tolerance bounds [min, max] around a value for a given epsilon

Parameters:

  • value (Numeric)

    center value

  • epsilon (Float) (defaults to: 1e-9)

    tolerance radius

Returns:

  • (Array<Numeric>)

    two-element array [value - epsilon, value + epsilon]



140
141
142
# File 'lib/philiprehberger/approx.rb', line 140

def self.tolerance_range(value, epsilon: 1e-9)
  [value - epsilon, value + epsilon]
end

.within?(a, b, abs: nil, rel: nil) ⇒ Boolean

Check if two values are approximately equal using combined tolerance

Passes if either absolute or relative tolerance is met. At least one of abs: or rel: must be provided.

Parameters:

  • a (Numeric, Array, Hash)

    first value

  • b (Numeric, Array, Hash)

    second value

  • abs (Float, nil) (defaults to: nil)

    absolute tolerance

  • rel (Float, nil) (defaults to: nil)

    relative tolerance

Returns:

  • (Boolean)

    true if values pass either tolerance check

Raises:

  • (ArgumentError)


82
83
84
85
86
# File 'lib/philiprehberger/approx.rb', line 82

def self.within?(a, b, abs: nil, rel: nil)
  raise ArgumentError, 'at least one of abs: or rel: must be provided' if abs.nil? && rel.nil?

  compare_within(a, b, abs, rel)
end

.zero?(value, epsilon: 1e-9) ⇒ Boolean

Check if a numeric value is approximately zero

Parameters:

  • value (Numeric)

    value to test

  • epsilon (Float) (defaults to: 1e-9)

    maximum allowed difference from zero

Returns:

  • (Boolean)

    true if |value| <= epsilon



120
121
122
# File 'lib/philiprehberger/approx.rb', line 120

def self.zero?(value, epsilon: 1e-9)
  value.abs <= epsilon
end