Class: Ruby::Rego::Number
- Inherits:
-
Numeric
- Object
- Numeric
- Ruby::Rego::Number
- 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.5and1.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_rmaterializes 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
productDoS gate). Math.log10(2)
- INT64_MIN =
The signed 64-bit integer range. OPA’s
sumtakes its exact-integer fast-path for an element only when Go’sjson.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 (0or[1-9]\d*), an optional.fraction, an optionale/Eexponent. The single authoritative source; the JSON decoder derives its scannableJsonDecoder::NUMBERfrom this andDECIMAL_STRINGanchors it, so the grammar can never silently drift between the three sites. /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/- DECIMAL_STRING =
NUMBER_COREanchored to a whole string. Used byto_numberto validate a full string the way the lexer/decoder validate a token (rejects007,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, andmagnitude_within_limit?share for their literal-vs-Integer dispatch. /[.eE]/
Class Method Summary collapse
-
.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 form0would drop OPA’s verbatim sign — becomes a text-preserving Number; a plain integer becomes an exact Integer. -
.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). -
.finite_real?(value) ⇒ Boolean
Whether
valueis a real number this engine can ORDER and FOLD without crashing: a Number, a Ruby Integer or Rational, or a FINITE Float. -
.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.0is2and1e308 * 1e308is9999999999999999999followed by zeros, matching OPA, not the exact binary...9114207...); a fractional result becomes a Number carrying its Go ‘g’-formatted text. -
.from_numeric(value) ⇒ Number
Wrap any Ruby Numeric as a Number for use as an arithmetic operand.
-
.integer_value(value) ⇒ Integer?
The exact Integer value of an integer-valued operand, or nil when it is not integer-valued.
-
.literal(text) ⇒ Number
Build a Number from a numeric literal’s source text (already validated by the lexer).
-
.magnitude_within_limit?(text, fractional: text.match?(FRACTIONAL)) ⇒ Boolean
Whether
text’s decimal order of magnitude is within OPA’s literal limit. -
.modulo(left, right) ⇒ Integer?
Rego modulo is integer-only and undefined otherwise: both operands must be integer-VALUED (so
4.0 % 2-> 0 but5.5 % 2is undefined), and the result is Go’s truncated remainder, taking the sign of the dividend (-5 % 3-> -2). -
.negate_literal(value) ⇒ Number, Integer
Negate a numeric literal value while PRESERVING its text (so
-1.50keeps-1.50, not-1.5): a Number toggles the sign of its text; a plain Integer negates normally. -
.prec64_multiply_truncate(rational, integer) ⇒ Integer
Multiply an exact
rationalby an exactintegerin 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). -
.product(numbers) ⇒ Number, Integer
Multiply every element of
numbersin the precision-64 big.Float context, reproducing OPA’sproductaggregate byte-for-byte. -
.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.
-
.sum(numbers) ⇒ Number, Integer
Sum a collection of numbers reproducing OPA’s
sumaggregate. -
.zero_literal?(text) ⇒ Boolean
Whether
textdenotes 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.
Instance Method Summary collapse
-
#*(other) ⇒ Object
-
#+(other) ⇒ Object
-
#-(other) ⇒ Object
-
#-@ ⇒ 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). -
#/(other) ⇒ Object
-
#<=>(other) ⇒ Integer?
Order against any Numeric by exact value (so 1.50 <=> 1.5 is 0 and 1.0 <=> 1 is 0).
-
#abs ⇒ Number, 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.
-
#binary_value ⇒ Rational, Integer
protected
The exact Rational of the precision-64 binary value — what OPA’s round/ceil/floor see.
-
#ceil(_ndigits = 0) ⇒ Integer
-
#coerce(other) ⇒ Array(Number, Number)
Let
Integer <op> Number/Float <op> Numberroute through Number’s flt arithmetic and exact-value ordering, preserving operand order for the non-commutative operators. -
#eql?(other) ⇒ Boolean
-
#exact ⇒ Rational, Integer
Exact value for equality / ordering / canonicalization: a Rational, or an Integer when the value is integer-valued.
-
#floor(_ndigits = 0) ⇒ Integer
-
#hash ⇒ Integer
-
#initialize(text: nil, exact: nil) ⇒ Number
constructor
A new instance of Number.
-
#integer_valued? ⇒ Boolean
-
#negative? ⇒ Boolean
-
#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) andround(1e400)yields the big.Float-rounded integer byte-for-byte. -
#to_binnum ⇒ Flt::BinNum
protected
-
#to_f ⇒ Float
Lossy, like Go’s float64 conversion (may be ±Infinity for huge magnitudes).
-
#to_i ⇒ Integer
(also: #to_int)
Truncated toward zero.
-
#to_json(*_args) ⇒ String
Emit as a raw JSON number token (valid JSON: the canonical decimal text).
-
#to_r ⇒ Rational
-
#to_s ⇒ String
(also: #inspect)
The canonical decimal text.
-
#truncate(_ndigits = 0) ⇒ Integer
-
#zero? ⇒ Boolean
Constructor Details
#initialize(text: nil, exact: nil) ⇒ Number
Returns a new instance of Number.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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).
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.
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).
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.
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).
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).
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 |
#abs ⇒ Number, 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.
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_value ⇒ Rational, Integer (protected)
The exact Rational of the precision-64 binary value — what OPA’s round/ceil/floor see.
691 692 693 |
# File 'lib/ruby/rego/number.rb', line 691 def binary_value to_binnum.to_r end |
#ceil(_ndigits = 0) ⇒ 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.
439 440 441 |
# File 'lib/ruby/rego/number.rb', line 439 def coerce(other) [self.class.from_numeric(other), self] end |
#eql?(other) ⇒ Boolean
476 477 478 |
# File 'lib/ruby/rego/number.rb', line 476 def eql?(other) other.is_a?(Number) && exact == other.exact end |
#exact ⇒ Rational, Integer
Exact value for equality / ordering / canonicalization: a Rational, or an Integer when the value is integer-valued.
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
405 406 407 |
# File 'lib/ruby/rego/number.rb', line 405 def floor(_ndigits = 0) binary_value.floor end |
#hash ⇒ Integer
471 472 473 |
# File 'lib/ruby/rego/number.rb', line 471 def hash exact.hash end |
#integer_valued? ⇒ Boolean
357 358 359 |
# File 'lib/ruby/rego/number.rb', line 357 def integer_valued? exact.is_a?(Integer) end |
#negative? ⇒ 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.
395 396 397 |
# File 'lib/ruby/rego/number.rb', line 395 def round(_ndigits = 0) binary_value.round end |
#to_binnum ⇒ Flt::BinNum (protected)
685 686 687 |
# File 'lib/ruby/rego/number.rb', line 685 def to_binnum self.class.rational_to_binnum(exact) end |
#to_f ⇒ Float
Returns 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_i ⇒ Integer Also known as: to_int
Returns 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.
344 345 346 |
# File 'lib/ruby/rego/number.rb', line 344 def to_json(*_args) to_s end |
#to_r ⇒ Rational
383 384 385 |
# File 'lib/ruby/rego/number.rb', line 383 def to_r exact.to_r end |
#to_s ⇒ String 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.
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
410 411 412 |
# File 'lib/ruby/rego/number.rb', line 410 def truncate(_ndigits = 0) binary_value.to_i end |
#zero? ⇒ Boolean
362 363 364 |
# File 'lib/ruby/rego/number.rb', line 362 def zero? exact.zero? end |