Class: Ruby::Rego::Number

Inherits:
Numeric
  • Object
show all
Defined in:
lib/ruby/rego/number.rb

Overview

An OPA-faithful arbitrary-precision number.

OPA stores a Rego number as a json.Number — the original decimal TEXT, preserved verbatim (1.50 stays 1.50, 1e999 stays 1e999) — and does arithmetic in Go’s math/big.Float at 64-bit binary precision with round-half-even, formatting the result with Go’s FloatToNumber rules. Ruby’s Float (IEEE-754 double) cannot match that: it loses precision past 2^53 and overflows to Float::INFINITY, which then crashes JSON serialization. This class reproduces OPA exactly:

  • a LITERAL carries its source text and serializes it back verbatim (#to_s / #to_json);
  • arithmetic runs through Flt::BinNum (precision 64, half-even — the math/big.Float equivalent), fed exact rationals so the rounding matches Go’s big.Float.SetString, and the result is formatted with Go’s strconv ‘g’ / ‘f’ conventions (GoNumberFormat);
  • equality and ordering use an exact Rational (#exact), so 1.50 == 1.5 and 1.0 == 1.

Integers stay Ruby Integer (already arbitrary-precision); only non-integers are Number. An arithmetic result that is integer-valued collapses back to a Ruby Integer, matching OPA (1.0 + 1.0 -> 2, 1e308 * 1e308 -> the full ~600-digit integer).

rubocop:disable Metrics/ClassLength – a cohesive Numeric value object: the full Numeric protocol (conversions, ordering, coercion, the four operators) plus the OPA div/mod helpers and the flt arithmetic backend belong together; splitting them would only scatter the contract.

Defined Under Namespace

Classes: MagnitudeError

Constant Summary collapse

ENGINE_EMAX =

The big.Float engine’s binary-exponent ceiling (and, negated, its floor): wide enough for any decimal literal OPA accepts (well beyond 1e±999). Exposed as a named constant — rather than buried in CONTEXT — so the units DoS-safety invariant test can assert against it without reaching into the CONTEXT object, keeping a single source of truth if the range is ever retuned.

2**30
CONTEXT =

Go’s math/big.Float number context: 64-bit binary precision, round-half-even, and the ENGINE_EMAX exponent range.

Flt::BinNum::Context(precision: 64, rounding: :half_even, emax: ENGINE_EMAX, emin: -ENGINE_EMAX)
MAX_MAGNITUDE_EXPONENT =

The largest decimal order of magnitude OPA accepts in a numeric literal: a literal whose magnitude exceeds 1030102 (or whose reciprocal does) is a parse error in OPA (“number too big”). It is also the guard against a denial of service — BigDecimal(text).to_r materializes a numerator or denominator of ~10|magnitude| as a full Integer, so an unbounded exponent (e.g. 1e999999999, 11 source bytes -> a gigabyte rational) would otherwise exhaust memory.

The cap matches OPA across the entire realistic range. Two known edges, both bounded and far beyond any real policy value (documented, not a DoS): (1) OPA’s bound is very slightly asymmetric (the tiny-magnitude side reaches ~10-30150); this symmetric cap is marginally stricter there. (2) At the extreme large edge the gem and OPA can differ by one order of magnitude: this gate uses BigDecimal’s EXACT magnitude, whereas OPA rounds the literal to a big.Float at its precision first, so a value just below 1030103 (e.g. a 30103-digit all-nines integer) rounds UP across OPA’s boundary and is rejected by OPA but accepted here. Verified vs opa eval 1.17. Matching OPA’s rounding-at-the-boundary behaviour would require porting its big.Float parser — a tracked follow-up in the number-model fidelity sweep, not this scope. Pre-existing; unaffected by the gate refactor.

30_102
LOG10_2 =

log10(2), for estimating a BinNum’s base-10 order of magnitude from its binary exponent without materializing the value. Used only by magnitude_exceeds_cap? (the product DoS gate).

Math.log10(2)
INT64_MIN =

The signed 64-bit integer range. OPA’s sum takes its exact-integer fast-path for an element only when Go’s json.Number.Int64() (= strconv.ParseInt(text, 10, 64)) succeeds — i.e. the element is plain-integer text within this range. sum mirrors that with “Ruby Integer within [INT64_MIN, INT64_MAX]”, an exact correspondence regardless of how the element was produced: both the lexer/decoder AND from_binnum apply the same split (integer-valued -> Ruby Integer, fractional/exponent -> Number), and OPA’s FloatToNumber renders an integer-valued result as plain-integer text, so a Ruby Integer (parsed literal or computed result) within int64 is exactly the value OPA’s Int64() accepts — and one outside int64 is exactly the value it rejects.

-(2**63)
INT64_MAX =
(2**63) - 1
NUMBER_CORE =

OPA’s strict JSON-number grammar, UNanchored: an optional leading -, no leading zeros (0 or [1-9]\d*), an optional . fraction, an optional e/E exponent. The single authoritative source; the JSON decoder derives its scannable JsonDecoder::NUMBER from this and DECIMAL_STRING anchors it, so the grammar can never silently drift between the three sites.

/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/
DECIMAL_STRING =

NUMBER_CORE anchored to a whole string. Used by to_number to validate a full string the way the lexer/decoder validate a token (rejects 007, 1., .5, +5, surrounding whitespace, hex, NaN/Infinity).

/\A#{NUMBER_CORE.source}\z/
FRACTIONAL =

A number token is a fractional/exponent form iff it carries ./e/E. The single predicate the decoder, build_number, and magnitude_within_limit? share for their literal-vs-Integer dispatch.

/[.eE]/

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(text: nil, exact: nil) ⇒ Number

Returns a new instance of Number.

Parameters:

  • text (String, nil) (defaults to: nil)

    canonical decimal text (authoritative for a literal)

  • exact (Rational, Integer, nil) (defaults to: nil)

    exact value (authoritative for an operand wrap)



323
324
325
326
327
# File 'lib/ruby/rego/number.rb', line 323

def initialize(text: nil, exact: nil)
  super()
  @text = text&.freeze
  @exact = normalize_exact(exact)
end

Class Method Details

.build_number(text, fractional: text.match?(FRACTIONAL)) ⇒ Number, Integer

Build a parsed value from already-grammar-validated number text: a fractional/exponent form — or -0, whose canonical Integer form 0 would drop OPA’s verbatim sign — becomes a text-preserving Number; a plain integer becomes an exact Integer. fractional is a dispatch hint: the JSON decoder threads its precomputed flag through; to_number passes nothing and lets the default re-derive it (it has already scanned the bounded-magnitude text, so the extra O(n) scan is immaterial there). It carries NO validation — the caller MUST have already gated the text’s grammar, encoding, and magnitude, because this materializes the value.

:reek:ControlParameter – fractional is a precomputed dispatch flag selecting the literal vs Integer branch, passed to skip a second O(n) scan of a large token; the default re-derives it.

Parameters:

  • text (String)
  • fractional (bool) (defaults to: text.match?(FRACTIONAL))

    whether text has ./e/E

Returns:



117
118
119
# File 'lib/ruby/rego/number.rb', line 117

def self.build_number(text, fractional: text.match?(FRACTIONAL))
  fractional || text == "-0" ? literal(text) : Integer(text, 10)
end

.div(left, right) ⇒ Number, Integer

Rego division is always big.Float (OPA 5 / 2 -> 2.5); an integer-valued quotient collapses to an Integer (4 / 2 -> 2). The caller guards a zero divisor to undefined.

Parameters:

  • left (Numeric)
  • right (Numeric)

Returns:



280
281
282
# File 'lib/ruby/rego/number.rb', line 280

def self.div(left, right)
  from_numeric(left) / right
end

.finite_real?(value) ⇒ Boolean

Whether value is a real number this engine can ORDER and FOLD without crashing: a Ruby::Rego::Number, a Ruby Integer or Rational, or a FINITE Float. Value.from_ruby admits ANY Ruby Numeric into a NumberValue (its non-finite guard is ::Float-only), so a host can pass exotic numerics through the library input: API; the numeric aggregates gate on this before sorting or folding so a rejected element maps to undefined instead of aborting the policy.

The accepted set is exactly #rational_of’s domain — the types <=> and the arithmetic operators can convert. This gate answers ONLY “will ordering/folding crash”; it deliberately does NOT bound magnitude. A compact over-cap Ruby::Rego::Number is accepted here: its #exact amplifies (Number("1e10000000") is ~12 bytes but its exact Rational is ten million digits), but that is a gem-wide Value/Number boundary concern, NOT an aggregate-gate one — it also amplifies at canonicalization (Value.canonicalize -> #exact, #hash -> #exact) the moment such a Number is put in a set/object, which runs BEFORE any aggregate, so a bound here would be asymmetric (arrays only) and could never close the case. Such a Number IS untrusted-reachable — not via the decoders (the lexer and JSON decoder reject a literal past MAX_MAGNITUDE_EXPONENT, to_number rejects it, yaml falls back to a string) but via the uncapped * / / operators, which match OPA’s value-returning big.Float by design (1e-30000 * 1e-30000 -> 1e-60000). It is a pre-existing number-model gap (the lazy #exact on a compact result), bounded by the engine emin (~hundreds of MB worst case), and NOT widened by this change — product is now the one path that can no longer manufacture such a result (see magnitude_exceeds_cap?). The fix belongs at the #exact/canonicalization boundary gem-wide (its own PR), not half-papered-over at this aggregate gate.

Rejected, each of which would otherwise crash a consumer: * Complex — no ordering (<=> returns nil, so a sort raises) and no big.Float conversion. * a non-finite Float — its to_r raises FloatDomainError; hence the finite? check. * a BigDecimal — #rational_of has no BigDecimal branch, so Number <=> BigDecimal returns nil and a mixed sort/compare raises. Admitting it would need a branch at the #rational_of / from_numeric level (which also governs arithmetic, where the same gap is a pre-existing crash) — a gem-wide change out of scope here — so BigDecimal is rejected uniformly. Any non-Numeric is rejected.

Parameters:

  • value (Object)

Returns:

  • (Boolean)


266
267
268
269
270
271
272
# File 'lib/ruby/rego/number.rb', line 266

def self.finite_real?(value)
  case value
  when Number, Integer, Rational then true
  when Float then value.finite?
  else false
  end
end

.from_binnum(binnum) ⇒ Number, Integer

Format a computed Flt::BinNum result the way OPA’s FloatToNumber does, working from the result’s SHORTEST round-tripping digits (not its exact binary value): an integer-valued result renders as Go’s ‘f’ verb — full decimal, no exponent — and collapses to a Ruby Integer (so 1.0 + 1.0 is 2 and 1e308 * 1e308 is 9999999999999999999 followed by zeros, matching OPA, not the exact binary ...9114207...); a fractional result becomes a Number carrying its Go ‘g’-formatted text.

OPA’s big.Float keeps IEEE signed zero (so product([0, -2]) and 0 / -1 format as “-0”), but this exact-Rational model collapses every zero to the unsigned Integer 0 (and BigDecimal("-0.0").to_r is already 0). Signed zero — across literals, operands, and computed results alike — is one tracked number-model gap, deferred to the dedicated number sweep rather than half-fixed here.

Parameters:

  • binnum (Flt::BinNum)

Returns:



194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/ruby/rego/number.rb', line 194

def self.from_binnum(binnum)
  # Pad the coefficient to the full 64-bit precision so flt's shortest format computes its
  # round-trip tolerance at 64 bits. An EXACT result (e.g. 1 - 0.25 -> 0.75, stored in 2 bits)
  # would otherwise be reported within its own narrow precision and mis-shorten (0.75 -> "0.8").
  binnum = CONTEXT.normalize(binnum) unless binnum.zero?
  digits, point = GoNumberFormat.shortest_digits(binnum.to_s(all_digits: false))
  negative = binnum.sign.negative?
  if binnum.integral?
    magnitude = GoNumberFormat.fixed(digits, point).to_i
    negative ? -magnitude : magnitude
  else
    new(text: GoNumberFormat.render(digits, point, negative))
  end
end

.from_numeric(value) ⇒ Number

Wrap any Ruby Numeric as a Number for use as an arithmetic operand.

Parameters:

  • value (Numeric)

Returns:



226
227
228
229
230
231
# File 'lib/ruby/rego/number.rb', line 226

def self.from_numeric(value)
  return value if value.is_a?(Number)
  return new(exact: value) if value.is_a?(Integer) || value.is_a?(Rational)

  new(exact: BigDecimal(value.to_s).to_r) # Float: via its shortest decimal, matching #exact
end

.integer_value(value) ⇒ Integer?

The exact Integer value of an integer-valued operand, or nil when it is not integer-valued.

Parameters:

  • value (Numeric)

Returns:

  • (Integer, nil)


304
305
306
307
308
309
310
# File 'lib/ruby/rego/number.rb', line 304

def self.integer_value(value)
  case value
  when Integer then value
  when Number then value.integer_valued? ? value.to_i : nil
  when Float then float_integer_value(value)
  end
end

.literal(text) ⇒ Number

Build a Number from a numeric literal’s source text (already validated by the lexer).

Parameters:

  • text (String)

Returns:



85
86
87
# File 'lib/ruby/rego/number.rb', line 85

def self.literal(text)
  new(text: text)
end

.magnitude_within_limit?(text, fractional: text.match?(FRACTIONAL)) ⇒ Boolean

Whether text’s decimal order of magnitude is within OPA’s literal limit. The single magnitude gate all three callers (the lexer, the JSON decoder, and to_number) share. A fractional/exponent form reads its magnitude from BigDecimal’s exponent (which records the position of the decimal point WITHOUT materializing 10**exponent), so this is O(text length) and safe on attacker-controlled input — unlike the to_r that #exact would later perform; a plain integer is checked by digit count alone. The lexer calls this to reject an over-large literal as a parse error, very nearly as OPA does (the bound is exact across the entire realistic range; at the extreme edge it can differ by one order of magnitude — see MAX_MAGNITUDE_EXPONENT).

An exponent literal of ~19+ digits silently saturates BigDecimal at construction (it does NOT raise): a huge POSITIVE exponent (1e9999999999999999999) becomes Infinity, and a huge NEGATIVE one (1e-9999999999999999999) underflows to 0 — both with exponent 0, which would slip past the magnitude check. An accepted-but-saturated number then crashes (Infinity -> FloatDomainError on the later #exact) or silently mis-evaluates as 0. The finite? guard rejects the positive case; the zero_literal? check distinguishes a genuine zero from an underflowed tiny non-zero, rejecting the latter. Both directions thus map to a parse/argument error, consistent with the cap and safer than OPA (which stores such a number as text and then panics on comparison).

:reek:ControlParameter – fractional is a precomputed dispatch flag the callers pass to skip a second O(n) scan of a huge token; it legitimately selects the integer vs decimal magnitude path.

Parameters:

  • text (String)
  • fractional (bool) (defaults to: text.match?(FRACTIONAL))

    whether text is a fractional/exponent form (has ./e/E). The JSON decoder and the lexer each precompute this for their literal-vs-Integer dispatch and thread it here to avoid a second full-string scan on an attacker-controlled megabyte token; only to_number passes nothing and the default re-derives it.

Returns:

  • (Boolean)


148
149
150
151
152
153
154
155
156
# File 'lib/ruby/rego/number.rb', line 148

def self.magnitude_within_limit?(text, fractional: text.match?(FRACTIONAL))
  return integer_magnitude_within_limit?(text) unless fractional

  decimal = BigDecimal(text)
  return false unless decimal.finite?
  return zero_literal?(text) if decimal.zero?

  (decimal.exponent - 1).abs <= MAX_MAGNITUDE_EXPONENT
end

.modulo(left, right) ⇒ Integer?

Rego modulo is integer-only and undefined otherwise: both operands must be integer-VALUED (so 4.0 % 2 -> 0 but 5.5 % 2 is undefined), and the result is Go’s truncated remainder, taking the sign of the dividend (-5 % 3 -> -2). Returns nil when an operand is not integer-valued, so the caller maps it to undefined. The caller guards a zero divisor.

Parameters:

  • left (Numeric)
  • right (Numeric)

Returns:

  • (Integer, nil)


292
293
294
295
296
297
298
# File 'lib/ruby/rego/number.rb', line 292

def self.modulo(left, right)
  dividend = integer_value(left)
  divisor = integer_value(right)
  return nil unless dividend && divisor

  dividend.remainder(divisor)
end

.negate_literal(value) ⇒ Number, Integer

Negate a numeric literal value while PRESERVING its text (so -1.50 keeps -1.50, not -1.5): a Number toggles the sign of its text; a plain Integer negates normally. Used by the parser when folding a unary minus directly onto a literal.

Parameters:

Returns:



215
216
217
218
219
220
# File 'lib/ruby/rego/number.rb', line 215

def self.negate_literal(value)
  return -value unless value.is_a?(Number)

  text = value.to_s
  literal(text.start_with?("-") ? text.delete_prefix("-") : "-#{text}")
end

.prec64_multiply_truncate(rational, integer) ⇒ Integer

Multiply an exact rational by an exact integer in the precision-64 big.Float context and truncate the product toward zero — reproducing OPA’s units.parse_bytes arithmetic byte-for-byte (Go math/big: big.Float.SetString → Mul → Int). rational_to_binnum rounds the rational to the float big.Float.SetString would yield, CONTEXT.multiply rounds the product again at 64 bits like Mul, and to_i truncates toward zero like Int (so a negative is NOT floored). The integer multiplier must be a uint64 (0..264-1), matching OPA’s m.SetUint64: every uint64 is exact at precision 64, so CONTEXT.Num never rounds it (OPA’s unit multipliers are all <= 260). Encapsulates the Flt engine so callers never touch CONTEXT directly. The caller must bound the operands so the product stays finite (see the units guards).

Parameters:

  • rational (Rational)
  • integer (Integer)

Returns:

  • (Integer)


504
505
506
507
508
509
510
511
512
513
514
# File 'lib/ruby/rego/number.rb', line 504

def self.prec64_multiply_truncate(rational, integer)
  # Fail fast on an out-of-contract multiplier rather than let CONTEXT.Num silently round it to a
  # different value (a wrong result). The contract is uint64 (OPA's SetUint64 domain), not a
  # bit_length test — every uint64 is exact at prec 64, and this also rejects negatives. OPA's unit
  # multipliers are all <= 2**60, so this never fires in practice; it protects a future/incorrect caller.
  unless integer.between?(0, (2**64) - 1)
    raise ArgumentError, "multiplier #{integer} is outside the uint64 range (OPA multiplies via SetUint64)"
  end

  CONTEXT.multiply(rational_to_binnum(rational), CONTEXT.Num(integer)).to_i
end

.product(numbers) ⇒ Number, Integer

Multiply every element of numbers in the precision-64 big.Float context, reproducing OPA’s product aggregate byte-for-byte. OPA seeds a big.Float at 1 and folds each element through Mul at precision 64; it has NO integer fast-path (unlike sum), so an all-integer product is the prec-64-ROUNDED value, not the exact integer ([2**32, 2**32, 2**32] -> the big.Float rounding of 2**96, which FloatToNumber renders shortest, NOT 79228162514264337593543950336). The accumulator therefore stays a BinNum across the whole fold: collapsing an integer-valued intermediate back to Integer (as the * operator does) would resume EXACT native integer multiplication and diverge from OPA. Each element is taken as its exact value first (a raw Float via its shortest decimal, like the literal OPA parsed), then rounded to the prec-64 float OPA’s NumberToFloat yields. An empty numbers returns the seed, formatting to Integer 1.

Integer-valued products inherit the number model’s shortest-form limitation shared with the other big.Float paths (div, sum, and a * with a fractional operand — integer * stays exact native bignum and is unaffected): flt’s and Go strconv’s shortest round-tripping decimals can tie-break differently on a value past prec-64 ([2**32]*3 -> …594 here vs OPA’s …590). This is tie-driven, not magnitude-gated — it can appear at moderate magnitudes (e.g. 2**65 ~ 3.7e19, 20 digits), not only “at the extreme”. Magnitude-correct and round-tripping to the same prec-64 float; pre-existing, tracked in the number sweep.

DoS: product is the one numeric builtin with an UNBOUNDED fold (N comprehension-controlled elements, each near the literal cap), so it is the only one whose result magnitude can grow without bound from small input — N near-cap factors give an N x cap result. A single op like * or / only ~doubles the magnitude, so those are deliberately left uncapped to MATCH OPA (1e308 * 1e308 -> the full ~600-digit integer, as the number model intends); this cap does NOT claim a global magnitude invariant. (Caveat: that uncapped *// is itself the untrusted-reachable manufacturing site of a compact over-cap Number — 1e-30000 * 1e-30000 -> 1e-60000, doubling per step in a squaring chain — whose lazy #exact amplifies on canonicalization/compare. That is a pre-existing number-model gap, emin-bounded, deferred to the number-model PR — see finite_real?.) Two gates keep product total: the engine’s Overflow trap stops an intermediate beyond ENGINE_EMAX (a ~10**9-digit integer) mid-fold, and magnitude_exceeds_cap? rejects a FINAL result past MAX_MAGNITUDE_EXPONENT before from_binnum would materialize it as a multi-megabyte Integer string. Both map to undefined at the builtin layer. The result cap is stricter than OPA — OPA would return e.g. product([1e20000, 1e20000]) = 1e40000 — but bounding an unbounded fold is the established DoS posture (the literal magnitude cap, the re2 caps), and OPA is itself unusable at the genuinely large, non-power-of-ten end of this range (>120s).

Parameters:

  • numbers (Array<Numeric>)

Returns:

Raises:

  • (MagnitudeError)

    when an intermediate overflows ENGINE_EMAX or the final magnitude exceeds MAX_MAGNITUDE_EXPONENT; the builtin layer maps it to undefined.



557
558
559
560
561
562
# File 'lib/ruby/rego/number.rb', line 557

def self.product(numbers)
  binnum = fold(numbers, :multiply, CONTEXT.Num(1))
  raise MagnitudeError, "product result exceeds the supported magnitude range" if magnitude_exceeds_cap?(binnum)

  from_binnum(binnum)
end

.rational_to_binnum(rational) ⇒ Flt::BinNum

Round an exact rational to the prec-64 binary float context, mirroring Go’s big.Float.SetString: numerator and denominator are each exact in the context, then divided, so the rounding lands on the same float OPA would compute for that literal.

Parameters:

  • rational (Rational, Integer)

Returns:

  • (Flt::BinNum)


486
487
488
489
# File 'lib/ruby/rego/number.rb', line 486

def self.rational_to_binnum(rational)
  rational = rational.to_r
  CONTEXT.divide(CONTEXT.Num(rational.numerator), CONTEXT.Num(rational.denominator))
end

.sum(numbers) ⇒ Number, Integer

Sum a collection of numbers reproducing OPA’s sum aggregate. OPA has two paths (v1/topdown /aggregates.go): an integer fast-path that accumulates every element through Go’s json.Number.Int64() when ALL elements are plain-integer text within int64, returning the int64 total; otherwise a prec-64 big.Float fold (seed 0, Add) formatted with FloatToNumber. The discriminator is per-element and ALL-or-nothing — a single non-int64 element (an exponent/decimal Number, or a plain integer beyond int64) sends the WHOLE fold through the big.Float, where the result is the prec-64-ROUNDED value (sum([1e20, 7]) -> 100000000000000000010, not the exact …007). Empty -> 0. Sets are deduplicated and folded in ascending order by the builtin layer.

ONE deliberate divergence from OPA, applying the project’s “implement correctly, document the divergence” precedent for upstream bugs: OPA’s int64 fast-path SILENTLY WRAPS on overflow (sum([9e18, 9e18]) -> -446744073709551616). This fast-path keeps the accumulator in arbitrary-precision Ruby Integer and returns the true sum (18000000000000000000) — replicating a silent integer-overflow wrap would turn a sum of positive quotas into a negative value, an authorization hazard. Below int64 overflow the two paths are identical.

Unlike product, sum carries NO magnitude cap: it grows only additively (result magnitude ~= max-element magnitude + log10(N)), so a sum whose magnitude exceeds the literal cap is still returned to match OPA (sum([1e60000, 1e60000]) -> 2e60000). It does still need the engine’s overflow trap as a totality backstop, though: a single element past ENGINE_EMAX — reachable via the library input: API (which, unlike the JSON decoder, does not magnitude-cap a Ruby Integer) or via uncapped integer * — would otherwise let fold raise an uncaught Flt::Num::Exception and abort the policy. fold maps that to a RangeError the builtin layer turns into undefined, mirroring product’s intermediate trap (the gem’s ENGINE_EMAX is stricter than Go’s big.Float range, the same documented stance product takes; no realistic sum reaches 10**~3e8). Because that trap fires before fold can yield a non-finite BinNum, the from_binnum below never sees a special value and so needs no special?-guard here (unlike magnitude_exceeds_cap? on product’s path).

Large integer-valued results inherit the number model’s shortest-form gap (see from_binnum / GoNumberFormat, unchanged here): flt’s and Go strconv’s shortest round-tripping decimals can tie-break differently, so e.g. sum([2**64, 2**64]) renders 36893488147419103232 here vs OPA’s 36893488147419103230. Magnitude-correct and round-tripping to the same prec-64 float; a tracked number-sweep item shared with the other big.Float paths (div, product, fractional *; integer * stays exact native bignum and is unaffected).

Parameters:

  • numbers (Array<Numeric>)

Returns:

Raises:

  • (MagnitudeError)

    when an element overflows ENGINE_EMAX; the builtin layer maps it to undefined.



666
667
668
669
670
# File 'lib/ruby/rego/number.rb', line 666

def self.sum(numbers)
  return numbers.sum if integer_fast_path?(numbers)

  from_binnum(fold(numbers, :add, CONTEXT.Num(0)))
end

.zero_literal?(text) ⇒ Boolean

Whether text denotes an exact zero (every significant digit is 0, e.g. “0”, “-0”, “0.0”, “0e1000”), as opposed to a tiny non-zero whose huge negative exponent underflowed BigDecimal to 0.

Parameters:

  • text (String)

Returns:

  • (Boolean)


177
178
179
# File 'lib/ruby/rego/number.rb', line 177

def self.zero_literal?(text)
  text.sub(/[eE].*/, "").delete("-+.").match?(/\A0+\z/)
end

Instance Method Details

#*(other) ⇒ Object



462
463
464
# File 'lib/ruby/rego/number.rb', line 462

def *(other)
  arithmetic(:multiply, other)
end

#+(other) ⇒ Object



454
455
456
# File 'lib/ruby/rego/number.rb', line 454

def +(other)
  arithmetic(:add, other)
end

#-(other) ⇒ Object



458
459
460
# File 'lib/ruby/rego/number.rb', line 458

def -(other)
  arithmetic(:subtract, other)
end

#-@Number

Negate, keeping the result a Number (it serializes to the same canonical text as the equivalent Integer — -(1.0) -> -1 — and equality unifies them). Deliberately NOT collapsed to a Ruby Integer: a huge integer-valued magnitude must stay a Number so yaml.marshal renders it through the OPA-faithful float64 path (-1e308 -> -1e+308); a Ruby Integer would render full digits, which is the pre-existing yaml-vs-OPA gap for large integers (tracked for the yaml/number sweep).

Returns:



450
451
452
# File 'lib/ruby/rego/number.rb', line 450

def -@
  self.class.from_numeric(-exact)
end

#/(other) ⇒ Object



466
467
468
# File 'lib/ruby/rego/number.rb', line 466

def /(other)
  arithmetic(:divide, other)
end

#<=>(other) ⇒ Integer?

Order against any Numeric by exact value (so 1.50 <=> 1.5 is 0 and 1.0 <=> 1 is 0).

Parameters:

  • other (Object)

Returns:

  • (Integer, nil)


427
428
429
430
431
432
# File 'lib/ruby/rego/number.rb', line 427

def <=>(other)
  rational = rational_of(other)
  return nil unless rational

  exact <=> rational
end

#absNumber, Integer

abs preserves the EXACT value (OPA’s abs keeps the json.Number: abs(-1e400) is the clean 10**400, not a big.Float-rounded value), unlike round/ceil/floor.

Returns:

  • (Number, Integer)

    Integer when the magnitude is integer-valued, else a Number



418
419
420
421
# File 'lib/ruby/rego/number.rb', line 418

def abs
  magnitude = exact.abs
  magnitude.is_a?(Integer) ? magnitude : self.class.from_numeric(magnitude)
end

#binary_valueRational, Integer (protected)

The exact Rational of the precision-64 binary value — what OPA’s round/ceil/floor see.

Returns:

  • (Rational, Integer)


691
692
693
# File 'lib/ruby/rego/number.rb', line 691

def binary_value
  to_binnum.to_r
end

#ceil(_ndigits = 0) ⇒ Integer

Returns:

  • (Integer)


400
401
402
# File 'lib/ruby/rego/number.rb', line 400

def ceil(_ndigits = 0)
  binary_value.ceil
end

#coerce(other) ⇒ Array(Number, Number)

Let Integer <op> Number / Float <op> Number route through Number’s flt arithmetic and exact-value ordering, preserving operand order for the non-commutative operators.

Parameters:

  • other (Numeric)

Returns:



439
440
441
# File 'lib/ruby/rego/number.rb', line 439

def coerce(other)
  [self.class.from_numeric(other), self]
end

#eql?(other) ⇒ Boolean

Returns:

  • (Boolean)


476
477
478
# File 'lib/ruby/rego/number.rb', line 476

def eql?(other)
  other.is_a?(Number) && exact == other.exact
end

#exactRational, Integer

Exact value for equality / ordering / canonicalization: a Rational, or an Integer when the value is integer-valued.

Returns:

  • (Rational, Integer)


352
353
354
# File 'lib/ruby/rego/number.rb', line 352

def exact
  @exact ||= normalize_exact(BigDecimal(to_s).to_r)
end

#floor(_ndigits = 0) ⇒ Integer

Returns:

  • (Integer)


405
406
407
# File 'lib/ruby/rego/number.rb', line 405

def floor(_ndigits = 0)
  binary_value.floor
end

#hashInteger

Returns:

  • (Integer)


471
472
473
# File 'lib/ruby/rego/number.rb', line 471

def hash
  exact.hash
end

#integer_valued?Boolean

Returns:

  • (Boolean)


357
358
359
# File 'lib/ruby/rego/number.rb', line 357

def integer_valued?
  exact.is_a?(Integer)
end

#negative?Boolean

Returns:

  • (Boolean)


367
368
369
# File 'lib/ruby/rego/number.rb', line 367

def negative?
  exact.negative?
end

#round(_ndigits = 0) ⇒ Integer

round / ceil / floor / truncate operate on the PRECISION-64 binary value (#to_binnum), exactly as OPA’s round/ceil/floor do (they convert the json.Number to a big.Float at precision 64 and round that), so a value within half an ulp of a half-integer rounds as OPA does (round(0.4999…9) -> 1) and round(1e400) yields the big.Float-rounded integer byte-for-byte. They never route through to_f (which would overflow to Infinity and raise FloatDomainError). Rounding is half-away-from-zero, matching OPA.

Returns:

  • (Integer)


395
396
397
# File 'lib/ruby/rego/number.rb', line 395

def round(_ndigits = 0)
  binary_value.round
end

#to_binnumFlt::BinNum (protected)

Returns:

  • (Flt::BinNum)


685
686
687
# File 'lib/ruby/rego/number.rb', line 685

def to_binnum
  self.class.rational_to_binnum(exact)
end

#to_fFloat

Returns lossy, like Go’s float64 conversion (may be ±Infinity for huge magnitudes).

Returns:

  • (Float)

    lossy, like Go’s float64 conversion (may be ±Infinity for huge magnitudes)



372
373
374
# File 'lib/ruby/rego/number.rb', line 372

def to_f
  to_s.to_f
end

#to_iInteger Also known as: to_int

Returns truncated toward zero.

Returns:

  • (Integer)

    truncated toward zero



377
378
379
# File 'lib/ruby/rego/number.rb', line 377

def to_i
  exact.to_i
end

#to_json(*_args) ⇒ String

Emit as a raw JSON number token (valid JSON: the canonical decimal text). JSON.generate dispatches here for a custom Numeric, so a Number serializes with full fidelity and never as a non-finite token.

Returns:

  • (String)


344
345
346
# File 'lib/ruby/rego/number.rb', line 344

def to_json(*_args)
  to_s
end

#to_rRational

Returns:

  • (Rational)


383
384
385
# File 'lib/ruby/rego/number.rb', line 383

def to_r
  exact.to_r
end

#to_sString Also known as: inspect

The canonical decimal text. For a literal this is the verbatim source; for a computed result the Go-formatted shortest text; for an operand wrap it is derived from the exact value on demand.

Returns:

  • (String)


333
334
335
# File 'lib/ruby/rego/number.rb', line 333

def to_s
  @text ||= self.class.from_binnum(to_binnum).to_s # rubocop:disable Naming/MemoizedInstanceVariableName
end

#truncate(_ndigits = 0) ⇒ Integer

Returns:

  • (Integer)


410
411
412
# File 'lib/ruby/rego/number.rb', line 410

def truncate(_ndigits = 0)
  binary_value.to_i
end

#zero?Boolean

Returns:

  • (Boolean)


362
363
364
# File 'lib/ruby/rego/number.rb', line 362

def zero?
  exact.zero?
end