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.12.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



147
148
149
150
151
# File 'lib/philiprehberger/approx.rb', line 147

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



209
210
211
212
213
# File 'lib/philiprehberger/approx.rb', line 209

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



169
170
171
# File 'lib/philiprehberger/approx.rb', line 169

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



136
137
138
# File 'lib/philiprehberger/approx.rb', line 136

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



275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/philiprehberger/approx.rb', line 275

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



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/philiprehberger/approx.rb', line 237

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

.monotonic?(values, direction: :increasing, epsilon: nil, rel_tol: 0.0) ⇒ Boolean

Check if a sequence is approximately monotonic under the tolerance model of .equal?

Pairwise compares adjacent elements using the same absolute/relative tolerance helpers as .equal?. For strict directions (:increasing, :decreasing) each pair must satisfy the strict inequality AND must not be approximately equal within the configured tolerance. For non-strict directions (:non_decreasing, :non_increasing) each pair must satisfy the non-strict inequality OR be approximately equal within the configured tolerance. Empty and single-element sequences return true.

Parameters:

  • values (Enumerable)

    collection of values to inspect

  • direction (Symbol) (defaults to: :increasing)

    one of :increasing, :decreasing, :non_decreasing, :non_increasing

  • epsilon (Float) (defaults to: nil)

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

  • rel_tol (Float) (defaults to: 0.0)

    relative tolerance (default 0 — disabled)

Returns:

  • (Boolean)

    true if the sequence is approximately monotonic in the requested direction

Raises:

  • (ArgumentError)

    if direction is not one of the four accepted symbols



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/philiprehberger/approx.rb', line 75

def self.monotonic?(values, direction: :increasing, epsilon: nil, rel_tol: 0.0)
  unless %i[increasing decreasing non_decreasing non_increasing].include?(direction)
    raise ArgumentError, "unknown direction: #{direction.inspect}"
  end

  arr = values.to_a
  return true if arr.length < 2

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

  arr.each_cons(2).all? do |a, b|
    near = equal?(a, b, **opts)
    case direction
    when :increasing     then a < b && !near
    when :decreasing     then a > b && !near
    when :non_decreasing then a <= b || near
    when :non_increasing then a >= b || near
    end
  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



221
222
223
# File 'lib/philiprehberger/approx.rb', line 221

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



106
107
108
# File 'lib/philiprehberger/approx.rb', line 106

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)



191
192
193
194
195
196
197
198
# File 'lib/philiprehberger/approx.rb', line 191

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]



178
179
180
# File 'lib/philiprehberger/approx.rb', line 178

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)


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

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



158
159
160
# File 'lib/philiprehberger/approx.rb', line 158

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