From d10bacf0962907da68508d8c56af79cc4ad662c3 Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Tue, 9 Aug 2022 19:15:06 +0200 Subject: [PATCH] Add exponent and logarithm mappings Fixes #2957 --- CHANGELOG.md | 2 + .../exponential_histogram/mapping/__init__.py | 85 ++++ .../exponential_histogram/mapping/errors.py | 26 ++ .../mapping/exponent_mapping.py | 125 ++++++ .../exponential_histogram/mapping/ieee_754.py | 181 +++++++++ .../mapping/logarithm_mapping.py | 140 +++++++ .../test_exponent_mapping.py | 378 ++++++++++++++++++ .../exponential_histogram/test_ieee_754.py | 92 +++++ .../test_logarithm_mapping.py | 226 +++++++++++ 9 files changed, 1255 insertions(+) create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/__init__.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/errors.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/exponent_mapping.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/ieee_754.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/logarithm_mapping.py create mode 100644 opentelemetry-sdk/tests/metrics/exponential_histogram/test_exponent_mapping.py create mode 100644 opentelemetry-sdk/tests/metrics/exponential_histogram/test_ieee_754.py create mode 100644 opentelemetry-sdk/tests/metrics/exponential_histogram/test_logarithm_mapping.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ea4f873b37d..1c3e219106b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/open-telemetry/opentelemetry-python/compare/v1.13.0-0.34b0...HEAD) +- Add logarithm and exponent mappings + ([#2960](https://github.com/open-telemetry/opentelemetry-python/pull/2960)) - Update explicit histogram bucket boundaries ([#2947](https://github.com/open-telemetry/opentelemetry-python/pull/2947)) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/__init__.py new file mode 100644 index 00000000000..7ab61ac8d67 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/__init__.py @@ -0,0 +1,85 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from abc import ABC, abstractmethod + + +class Mapping(ABC): + + # pylint: disable=no-member + def __new__(cls, scale: int): + + with cls._mappings_lock: + if scale not in cls._mappings: + cls._mappings[scale] = super().__new__(cls) + + return cls._mappings[scale] + + def __init__(self, scale: int) -> None: + + if scale > self._max_scale: + raise Exception(f"scale is larger than {self._max_scale}") + + if scale < self._min_scale: + raise Exception(f"scale is smaller than {self._min_scale}") + + # The size of the exponential histogram buckets is determined by a + # parameter known as scale, larger values of scale will produce smaller + # buckets. Bucket boundaries of the exponential histogram are located + # at integer powers of the base, where: + + # base = 2 ** (2 ** (-scale)) + self._scale = scale + + @property + @abstractmethod + def _min_scale(self) -> int: + """ + Return the smallest possible value for the mapping scale + """ + + @property + @abstractmethod + def _max_scale(self) -> int: + """ + Return the largest possible value for the mapping scale + """ + + @abstractmethod + def map_to_index(self, value: float) -> int: + """ + Maps positive floating point values to indexes corresponding to + `Mapping.scale`. Implementations are not expected to handle zeros, + +inf, NaN, or negative values. + """ + + @abstractmethod + def get_lower_boundary(self, index: int) -> float: + """ + Returns the lower boundary of a given bucket index. The index is + expected to map onto a range that is at least partially inside the + range of normalized floating point values. If the corresponding + bucket's upper boundary is less than or equal to 2 ** -1022, + `UnderflowError` will be raised. If the corresponding bucket's lower + boundary is greater than `sys.float_info.max`, `OverflowError` will be + raised. + """ + + @property + @abstractmethod + def scale(self) -> int: + """ + Returns the parameter that controls the resolution of this mapping. + See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/datamodel.md#exponential-scale + """ diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/errors.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/errors.py new file mode 100644 index 00000000000..54edb5fcd0b --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/errors.py @@ -0,0 +1,26 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class MappingUnderflowError(Exception): + """ + Raised when computing the lower boundary of an index that maps into a + denormalized floating point value. + """ + + +class MappingOverflowError(Exception): + """ + Raised when computing the lower boundary of an index that maps into +inf. + """ diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/exponent_mapping.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/exponent_mapping.py new file mode 100644 index 00000000000..54eabcfd88e --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/exponent_mapping.py @@ -0,0 +1,125 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from math import ldexp +from threading import Lock + +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping import ( + Mapping, +) +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.errors import ( + MappingOverflowError, + MappingUnderflowError, +) +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.ieee_754 import ( + MAX_NORMAL_EXPONENT, + MIN_NORMAL_EXPONENT, + MIN_NORMAL_VALUE, + SIGNIFICAND_WIDTH, + get_ieee_754_exponent, + get_ieee_754_significand, +) + + +class ExponentMapping(Mapping): + + _mappings = {} + _mappings_lock = Lock() + # _min_scale defines the point at which the exponential mapping function + # becomes useless for 64-bit floats. With scale -10, ignoring subnormal + # values, bucket indices range from -1 to 1. + _min_scale = -10 + # _max_scale is the largest scale supported by exponential mapping. Use + # a logarithm mapping for larger scales. + _max_scale = 0 + + def __init__(self, scale: int): + super().__init__(scale) + + # self._min_normal_lower_boundary_index is the index such that + # base ** index <= MIN_NORMAL_VALUE. An exponential histogram bucket + # with this index covers the range (base ** index, base (index + 1)], + # including MIN_NORMAL_VALUE. + index = MIN_NORMAL_EXPONENT >> -self._scale + + if -self._scale < 2: + index -= 1 + + self._min_normal_lower_boundary_index = index + + # self._max_normal_lower_boundary_index is the index such that + # base**index equals the greatest representable lower boundary. An + # exponential histogram bucket with this index covers the range + # ((2 ** 1024) / base, 2 ** 1024], which includes opentelemetry.sdk. + # metrics._internal.exponential_histogram.ieee_754.MAX_NORMAL_VALUE. + # This bucket is incomplete, since the upper boundary cannot be + # represented. One greater than this index corresponds with the bucket + # containing values > 2 ** 1024. + self._max_normal_lower_boundary_index = ( + MAX_NORMAL_EXPONENT >> -self._scale + ) + + def map_to_index(self, value: float) -> int: + if value < MIN_NORMAL_VALUE: + return self._min_normal_lower_boundary_index + + exponent = get_ieee_754_exponent(value) + + # Positive integers are represented in binary as having an infinite + # amount of leading zeroes, for example 2 is represented as ...00010. + + # A negative integer -x is represented in binary as the complement of + # (x - 1). For example, -4 is represented as the complement of 4 - 1 + # == 3. 3 is represented as ...00011. Its compliment is ...11100, the + # binary representation of -4. + + # get_ieee_754_significand(value) gets the positive integer made up + # from the rightmost SIGNIFICAND_WIDTH bits (the mantissa) of the IEEE + # 754 representation of value. If value is an exact power of 2, all + # these SIGNIFICAND_WIDTH bits would be all zeroes, and when 1 is + # subtracted the resulting value is -1. The binary representation of + # -1 is ...111, so when these bits are right shifted SIGNIFICAND_WIDTH + # places, the resulting value for correction is -1. If value is not an + # exact power of 2, at least one of the rightmost SIGNIFICAND_WIDTH + # bits would be 1 (even for values whose decimal part is 0, like 5.0 + # since the IEEE 754 of such number is too the product of a power of 2 + # (defined in the exponent part of the IEEE 754 representation) and the + # value defined in the mantissa). Having at least one of the rightmost + # SIGNIFICAND_WIDTH bit being 1 means that get_ieee_754(value) will + # always be greater or equal to 1, and when 1 is subtracted, the + # result will be greater or equal to 0, whose representation in binary + # will be of at most SIGNIFICAND_WIDTH ones that have an infinite + # amount of leading zeroes. When those SIGNIFICAND_WIDTH bits are + # shifted to the right SIGNIFICAND_WIDTH places, the resulting value + # will be 0. + + # In summary, correction will be -1 if value is a power of 2, 0 if not. + + # FIXME Document why we can assume value will not be 0, inf, or NaN. + correction = (get_ieee_754_significand(value) - 1) >> SIGNIFICAND_WIDTH + + return (exponent + correction) >> -self._scale + + def get_lower_boundary(self, index: int) -> float: + if index < self._min_normal_lower_boundary_index: + raise MappingUnderflowError() + + if index > self._max_normal_lower_boundary_index: + raise MappingOverflowError() + + return ldexp(1, index << -self._scale) + + @property + def scale(self) -> int: + return self._scale diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/ieee_754.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/ieee_754.py new file mode 100644 index 00000000000..4d3eb05d67f --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/ieee_754.py @@ -0,0 +1,181 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ctypes import c_double, c_uint64 +from decimal import Decimal +from sys import float_info + +# An IEEE 754 double-precision (64 bit) floating point number is represented +# as: 1 bit for sign, 11 bits for exponent and 52 bits for significand. Since +# these numbers are in a normalized form (in scientific notation), the first +# bit of the significand will always be 1. Because of that, that bit is not +# stored but implicit, to make room for one more bit and more precision. + +SIGNIFICAND_WIDTH = 52 +EXPONENT_WIDTH = 11 + +# This mask is equivalent to 52 "1" bits (there are 13 hexadecimal 4-bit "f"s +# in the significand mask, 13 * 4 == 52) or 0xfffffffffffff in hexadecimal. +SIGNIFICAND_MASK = (1 << SIGNIFICAND_WIDTH) - 1 + +# There are 11 bits for the exponent, but the exponent bias values 0 (11 "0" +# bits) and 2047 (11 "1" bits) have special meanings so the exponent range is +# from 1 to 2046. To calculate the exponent value, 1023 is subtracted from the +# exponent, so the exponent value range is from -1022 to +1023. +EXPONENT_BIAS = (2 ** (EXPONENT_WIDTH - 1)) - 1 + +# All the exponent mask bits are set to 1 for the 11 exponent bits. +EXPONENT_MASK = ((1 << EXPONENT_WIDTH) - 1) << SIGNIFICAND_WIDTH + +# The exponent mask bit is to 1 for the sign bit. +SIGN_MASK = 1 << (EXPONENT_WIDTH + SIGNIFICAND_WIDTH) + +MIN_NORMAL_EXPONENT = -EXPONENT_BIAS + 1 +MAX_NORMAL_EXPONENT = EXPONENT_BIAS + +# Smallest possible normal value (2.2250738585072014e-308) +# This value is the result of using the smallest possible number in the +# mantissa, 1.0000000000000000000000000000000000000000000000000000 (52 "0"s in +# the fractional part) = 1.0000000000000000 and a single "1" in the exponent. +# Finally 1.0000000000000000 * 2 ** -1022 = 2.2250738585072014e-308. +MIN_NORMAL_VALUE = float_info.min + +# Greatest possible normal value (1.7976931348623157e+308) +# The binary representation of a float in scientific notation uses (for the +# significand) one bit for the integer part (which is implicit) and 52 bits for +# the fractional part. Consider a float binary 1.111. It is equal to 1 + 1/2 + +# 1/4 + 1/8. The greatest possible value in the 52-bit significand would be +# then 1.1111111111111111111111111111111111111111111111111111 (52 "1"s in the +# fractional part) = 1.9999999999999998. Finally, +# 1.9999999999999998 * 2 ** 1023 = 1.7976931348623157e+308. +MAX_NORMAL_VALUE = float_info.max + + +def get_ieee_754_64_binary(value: float): + """ + The purpose of this function is to illustrate the IEEE 754 64-bit float + representation. + """ + result = bin(c_uint64.from_buffer(c_double(value)).value)[2:] + + if result == "0": + result = result * 64 + + if value > 0: + result = f"0{result}" + + decimal_exponent = 0 + + exponent = result[1:12] + + for index, bit in enumerate(reversed(exponent)): + if int(bit): + decimal_exponent += 2**index + + # 0 has a special representation in IEE 574, all exponent and mantissa bits + # are 0. The sign bit still represents its sign, so there is 0 (all bits + # are set to 0) and -0 (the first bit is 1, the rest are 0). + if value == 0: + implicit_bit = 0 + else: + implicit_bit = 1 + + decimal_exponent -= 1023 * implicit_bit + + decimal_mantissa = Decimal(implicit_bit) + + mantissa = result[12:] + + for index, bit in enumerate(mantissa): + if int(bit): + decimal_mantissa += Decimal(1) / Decimal(2 ** (index + 1)) + + sign = result[0] + + return { + "sign": sign, + "exponent": exponent, + "mantissa": mantissa, + # IEEE 754 can only exactly represent a discrete series of numbers, the + # intention of this field is to show the actual decimal value that is + # represented. + "decimal": str( + Decimal(-1 if int(sign) else 1) + * Decimal(2**decimal_exponent) + * decimal_mantissa + ), + } + + +def get_ieee_754_exponent(value: float) -> int: + """ + Gets the exponent of the IEEE 754 representation of a float. + """ + + # 0000 -> 0 + # 0001 -> 1 + # 0010 -> 2 + # 0011 -> 3 + + # 0100 -> 4 + # 0101 -> 5 + # 0110 -> 6 + # 0111 -> 7 + + # 1000 -> 8 + # 1001 -> 9 + # 1010 -> 10 + # 1011 -> 11 + + # 1100 -> 12 + # 1101 -> 13 + # 1110 -> 14 + # 1111 -> 15 + + # 0 & 10 == 0 + # 1 & 10 == 0 + # 2 & 10 == 2 + # 3 & 10 == 2 + # 4 & 10 == 0 + # 6 & 10 == 2 + + # 12 >> 2 == 3 + # 1 >> 2 == 0 + + return ( + ( + # This step gives the integer that corresponds to the IEEE 754 + # representation of a float. + c_uint64.from_buffer(c_double(value)).value + # This step isolates the exponent bits, turning every bit + # outside of the exponent field to 0. + & EXPONENT_MASK + ) + # This step moves the exponent bits to the right, removing the + # mantissa bits that were set to 0 by the previous step. This + # leaves the IEEE 754 exponent value, ready for the next step. + >> SIGNIFICAND_WIDTH + # This step subtracts the exponent bias from the IEEE 754 value, + # leaving the actual exponent value. + ) - EXPONENT_BIAS + + +def get_ieee_754_significand(value: float) -> int: + return ( + c_uint64.from_buffer(c_double(value)).value + # This stepe isolates the significand bits. There is no need to do any + # bit shifting as the significand bits are already the rightmost field + # in an IEEE 754 representation. + & SIGNIFICAND_MASK + ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/logarithm_mapping.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/logarithm_mapping.py new file mode 100644 index 00000000000..d70b3030b5c --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/exponential_histogram/mapping/logarithm_mapping.py @@ -0,0 +1,140 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from math import exp, floor, ldexp, log +from threading import Lock + +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping import ( + Mapping, +) +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.errors import ( + MappingOverflowError, + MappingUnderflowError, +) +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.ieee_754 import ( + MAX_NORMAL_EXPONENT, + MIN_NORMAL_EXPONENT, + MIN_NORMAL_VALUE, + get_ieee_754_exponent, + get_ieee_754_significand, +) + + +class LogarithmMapping(Mapping): + + _mappings = {} + _mappings_lock = Lock() + # _min_scale ensures that ExponentMapping is used for zero and negative + # scale values. + _min_scale = 1 + # FIXME Go implementation uses a value of 20 here, find out the right + # value for this implementation, more information here: + # https://github.com/lightstep/otel-launcher-go/blob/c9ca8483be067a39ab306b09060446e7fda65f35/lightstep/sdk/metric/aggregator/histogram/structure/README.md#mapping-function + # https://github.com/open-telemetry/opentelemetry-go/blob/0e6f9c29c10d6078e8131418e1d1d166c7195d61/sdk/metric/aggregator/exponential/mapping/logarithm/logarithm.go#L32-L45 + _max_scale = 20 + + def __init__(self, scale: int): + + super().__init__(scale) + + # self._scale_factor is defined as a multiplier because multiplication + # is faster than division. self._scale_factor is defined as: + # index = log(value) * self._scale_factor + # Where: + # index = log(value) / log(base) + # index = log(value) / log(2 ** (2 ** -scale)) + # index = log(value) / ((2 ** -scale) * log(2)) + # index = log(value) * ((1 / log(2)) * (2 ** scale)) + # self._scale_factor = ((1 / log(2)) * (2 ** scale)) + # self._scale_factor = (1 /log(2)) * (2 ** scale) + # self._scale_factor = ldexp(1 / log(2), scale) + # This implementation was copied from a Java prototype. See: + # https://github.com/newrelic-experimental/newrelic-sketch-java/blob/1ce245713603d61ba3a4510f6df930a5479cd3f6/src/main/java/com/newrelic/nrsketch/indexer/LogIndexer.java + # for the equations used here. + self._scale_factor = ldexp(1 / log(2), scale) + + # self._min_normal_lower_boundary_index is the index such that + # base ** index == MIN_NORMAL_VALUE. An exponential histogram bucket + # with this index covers the range + # (MIN_NORMAL_VALUE, MIN_NORMAL_VALUE * base]. One less than this index + # corresponds with the bucket containing values <= MIN_NORMAL_VALUE. + self._min_normal_lower_boundary_index = ( + MIN_NORMAL_EXPONENT << self._scale + ) + + # self._max_normal_lower_boundary_index is the index such that + # base**index equals the greatest representable lower boundary. An + # exponential histogram bucket with this index covers the range + # ((2 ** 1024) / base, 2 ** 1024], which includes opentelemetry.sdk. + # metrics._internal.exponential_histogram.ieee_754.MAX_NORMAL_VALUE. + # This bucket is incomplete, since the upper boundary cannot be + # represented. One greater than this index corresponds with the bucket + # containing values > 2 ** 1024. + self._max_normal_lower_boundary_index = ( + (MAX_NORMAL_EXPONENT + 1) << self._scale + ) - 1 + + def map_to_index(self, value: float) -> int: + """ + MapToIndex maps positive floating point values to indexes + corresponding to Scale(). Implementations are not expected + to handle zeros, +Inf, NaN, or negative values. + """ + + # Note: we can assume not a 0, Inf, or NaN; positive sign bit. + if value <= MIN_NORMAL_VALUE: + return self._min_normal_lower_boundary_index - 1 + + # Exact power-of-two correctness: an optional special case. + if get_ieee_754_significand(value) == 0: + exponent = get_ieee_754_exponent(value) + return (exponent << self._scale) - 1 + + # Non-power of two cases. Use Floor(x) to round the scaled + # logarithm. We could use Ceil(x)-1 to achieve the same + # result, though Ceil() is typically defined as -Floor(-x) + # and typically not performed in hardware, so this is likely + # less code. + index = floor(log(value) * self._scale_factor) + + max_ = self._max_normal_lower_boundary_index + + if index >= max_: + return max_ + + return index + + def get_lower_boundary(self, index: int) -> float: + + if index >= self._max_normal_lower_boundary_index: + if index == self._max_normal_lower_boundary_index: + return 2 * exp( + (index - (1 << self._scale)) / self._scale_factor + ) + raise MappingOverflowError() + + if index <= self._min_normal_lower_boundary_index: + if index == self._min_normal_lower_boundary_index: + return MIN_NORMAL_VALUE + if index == self._min_normal_lower_boundary_index - 1: + return ( + exp((index + (1 << self._scale)) / self._scale_factor) / 2 + ) + raise MappingUnderflowError() + + return exp(index / self._scale_factor) + + @property + def scale(self) -> int: + return self._scale diff --git a/opentelemetry-sdk/tests/metrics/exponential_histogram/test_exponent_mapping.py b/opentelemetry-sdk/tests/metrics/exponential_histogram/test_exponent_mapping.py new file mode 100644 index 00000000000..14c820df3f9 --- /dev/null +++ b/opentelemetry-sdk/tests/metrics/exponential_histogram/test_exponent_mapping.py @@ -0,0 +1,378 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from math import inf +from sys import float_info, version_info +from unittest import TestCase + +from pytest import mark + +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.errors import ( + MappingUnderflowError, +) +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.exponent_mapping import ( + ExponentMapping, +) +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.ieee_754 import ( + MAX_NORMAL_EXPONENT, + MAX_NORMAL_VALUE, + MIN_NORMAL_EXPONENT, + MIN_NORMAL_VALUE, +) + +if version_info >= (3, 9): + from math import nextafter + + +def rounded_boundary(scale: int, index: int) -> float: + result = 2**index + + for _ in range(scale, 0): + result = result * result + + return result + + +class TestExponentMapping(TestCase): + def test_singleton(self): + + self.assertIs(ExponentMapping(-3), ExponentMapping(-3)) + self.assertIsNot(ExponentMapping(-3), ExponentMapping(-5)) + + def test_exponent_mapping_0(self): + + try: + ExponentMapping(0) + + except Exception as error: + self.fail(f"Unexpected exception raised: {error}") + + def test_exponent_mapping_zero(self): + + exponent_mapping = ExponentMapping(0) + + # This is the equivalent to 1.1 in hexadecimal + hex_1_1 = 1 + (1 / 16) + + # Testing with values near +inf + self.assertEqual( + exponent_mapping.map_to_index(MAX_NORMAL_VALUE), + MAX_NORMAL_EXPONENT, + ) + self.assertEqual(exponent_mapping.map_to_index(MAX_NORMAL_VALUE), 1023) + self.assertEqual(exponent_mapping.map_to_index(2**1023), 1022) + self.assertEqual(exponent_mapping.map_to_index(2**1022), 1021) + self.assertEqual( + exponent_mapping.map_to_index(hex_1_1 * (2**1023)), 1023 + ) + self.assertEqual( + exponent_mapping.map_to_index(hex_1_1 * (2**1022)), 1022 + ) + + # Testing with values near 1 + self.assertEqual(exponent_mapping.map_to_index(4), 1) + self.assertEqual(exponent_mapping.map_to_index(3), 1) + self.assertEqual(exponent_mapping.map_to_index(2), 0) + self.assertEqual(exponent_mapping.map_to_index(1), -1) + self.assertEqual(exponent_mapping.map_to_index(0.75), -1) + self.assertEqual(exponent_mapping.map_to_index(0.51), -1) + self.assertEqual(exponent_mapping.map_to_index(0.5), -2) + self.assertEqual(exponent_mapping.map_to_index(0.26), -2) + self.assertEqual(exponent_mapping.map_to_index(0.25), -3) + self.assertEqual(exponent_mapping.map_to_index(0.126), -3) + self.assertEqual(exponent_mapping.map_to_index(0.125), -4) + + # Testing with values near 0 + self.assertEqual(exponent_mapping.map_to_index(2**-1022), -1023) + self.assertEqual( + exponent_mapping.map_to_index(hex_1_1 * (2**-1022)), -1022 + ) + self.assertEqual(exponent_mapping.map_to_index(2**-1021), -1022) + self.assertEqual( + exponent_mapping.map_to_index(hex_1_1 * (2**-1021)), -1021 + ) + self.assertEqual( + exponent_mapping.map_to_index(2**-1022), MIN_NORMAL_EXPONENT - 1 + ) + self.assertEqual( + exponent_mapping.map_to_index(2**-1021), MIN_NORMAL_EXPONENT + ) + # The smallest subnormal value in Python is 2 ** -1074 = 5e-324. + # This value is also the result of: + # s = 1 + # while s / 2: + # s = s / 2 + # s == 5e-324 + self.assertEqual( + exponent_mapping.map_to_index(2**-1074), MIN_NORMAL_EXPONENT - 1 + ) + + def test_exponent_mapping_min_scale(self): + + exponent_mapping = ExponentMapping(ExponentMapping._min_scale) + self.assertEqual(exponent_mapping.map_to_index(1.000001), 0) + self.assertEqual(exponent_mapping.map_to_index(1), -1) + self.assertEqual(exponent_mapping.map_to_index(float_info.max), 0) + self.assertEqual(exponent_mapping.map_to_index(float_info.min), -1) + + def test_invalid_scale(self): + with self.assertRaises(Exception): + ExponentMapping(1) + + with self.assertRaises(Exception): + ExponentMapping(ExponentMapping._min_scale - 1) + + def test_exponent_mapping_neg_one(self): + exponent_mapping = ExponentMapping(-1) + self.assertEqual(exponent_mapping.map_to_index(17), 2) + self.assertEqual(exponent_mapping.map_to_index(16), 1) + self.assertEqual(exponent_mapping.map_to_index(15), 1) + self.assertEqual(exponent_mapping.map_to_index(9), 1) + self.assertEqual(exponent_mapping.map_to_index(8), 1) + self.assertEqual(exponent_mapping.map_to_index(5), 1) + self.assertEqual(exponent_mapping.map_to_index(4), 0) + self.assertEqual(exponent_mapping.map_to_index(3), 0) + self.assertEqual(exponent_mapping.map_to_index(2), 0) + self.assertEqual(exponent_mapping.map_to_index(1.5), 0) + self.assertEqual(exponent_mapping.map_to_index(1), -1) + self.assertEqual(exponent_mapping.map_to_index(0.75), -1) + self.assertEqual(exponent_mapping.map_to_index(0.5), -1) + self.assertEqual(exponent_mapping.map_to_index(0.25), -2) + self.assertEqual(exponent_mapping.map_to_index(0.20), -2) + self.assertEqual(exponent_mapping.map_to_index(0.13), -2) + self.assertEqual(exponent_mapping.map_to_index(0.125), -2) + self.assertEqual(exponent_mapping.map_to_index(0.10), -2) + self.assertEqual(exponent_mapping.map_to_index(0.0625), -3) + self.assertEqual(exponent_mapping.map_to_index(0.06), -3) + + def test_exponent_mapping_neg_four(self): + exponent_mapping = ExponentMapping(-4) + self.assertEqual(exponent_mapping.map_to_index(float(0x1)), -1) + self.assertEqual(exponent_mapping.map_to_index(float(0x10)), 0) + self.assertEqual(exponent_mapping.map_to_index(float(0x100)), 0) + self.assertEqual(exponent_mapping.map_to_index(float(0x1000)), 0) + self.assertEqual( + exponent_mapping.map_to_index(float(0x10000)), 0 + ) # base == 2 ** 16 + self.assertEqual(exponent_mapping.map_to_index(float(0x100000)), 1) + self.assertEqual(exponent_mapping.map_to_index(float(0x1000000)), 1) + self.assertEqual(exponent_mapping.map_to_index(float(0x10000000)), 1) + self.assertEqual( + exponent_mapping.map_to_index(float(0x100000000)), 1 + ) # base == 2 ** 32 + + self.assertEqual(exponent_mapping.map_to_index(float(0x1000000000)), 2) + self.assertEqual( + exponent_mapping.map_to_index(float(0x10000000000)), 2 + ) + self.assertEqual( + exponent_mapping.map_to_index(float(0x100000000000)), 2 + ) + self.assertEqual( + exponent_mapping.map_to_index(float(0x1000000000000)), 2 + ) # base == 2 ** 48 + + self.assertEqual( + exponent_mapping.map_to_index(float(0x10000000000000)), 3 + ) + self.assertEqual( + exponent_mapping.map_to_index(float(0x100000000000000)), 3 + ) + self.assertEqual( + exponent_mapping.map_to_index(float(0x1000000000000000)), 3 + ) + self.assertEqual( + exponent_mapping.map_to_index(float(0x10000000000000000)), 3 + ) # base == 2 ** 64 + + self.assertEqual( + exponent_mapping.map_to_index(float(0x100000000000000000)), 4 + ) + self.assertEqual( + exponent_mapping.map_to_index(float(0x1000000000000000000)), 4 + ) + self.assertEqual( + exponent_mapping.map_to_index(float(0x10000000000000000000)), 4 + ) + self.assertEqual( + exponent_mapping.map_to_index(float(0x100000000000000000000)), 4 + ) # base == 2 ** 80 + self.assertEqual( + exponent_mapping.map_to_index(float(0x1000000000000000000000)), 5 + ) + + self.assertEqual(exponent_mapping.map_to_index(1 / float(0x1)), -1) + self.assertEqual(exponent_mapping.map_to_index(1 / float(0x10)), -1) + self.assertEqual(exponent_mapping.map_to_index(1 / float(0x100)), -1) + self.assertEqual(exponent_mapping.map_to_index(1 / float(0x1000)), -1) + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x10000)), -2 + ) # base == 2 ** -16 + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x100000)), -2 + ) + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x1000000)), -2 + ) + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x10000000)), -2 + ) + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x100000000)), -3 + ) # base == 2 ** -32 + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x1000000000)), -3 + ) + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x10000000000)), -3 + ) + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x100000000000)), -3 + ) + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x1000000000000)), -4 + ) # base == 2 ** -32 + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x10000000000000)), -4 + ) + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x100000000000000)), -4 + ) + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x1000000000000000)), -4 + ) + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x10000000000000000)), -5 + ) # base == 2 ** -64 + self.assertEqual( + exponent_mapping.map_to_index(1 / float(0x100000000000000000)), -5 + ) + + self.assertEqual(exponent_mapping.map_to_index(float_info.max), 63) + self.assertEqual(exponent_mapping.map_to_index(2**1023), 63) + self.assertEqual(exponent_mapping.map_to_index(2**1019), 63) + self.assertEqual(exponent_mapping.map_to_index(2**1009), 63) + self.assertEqual(exponent_mapping.map_to_index(2**1008), 62) + self.assertEqual(exponent_mapping.map_to_index(2**1007), 62) + self.assertEqual(exponent_mapping.map_to_index(2**1000), 62) + self.assertEqual(exponent_mapping.map_to_index(2**993), 62) + self.assertEqual(exponent_mapping.map_to_index(2**992), 61) + self.assertEqual(exponent_mapping.map_to_index(2**991), 61) + + self.assertEqual(exponent_mapping.map_to_index(2**-1074), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1073), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1072), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1057), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1056), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1041), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1040), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1025), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1024), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1023), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1022), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1009), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1008), -64) + self.assertEqual(exponent_mapping.map_to_index(2**-1007), -63) + self.assertEqual(exponent_mapping.map_to_index(2**-993), -63) + self.assertEqual(exponent_mapping.map_to_index(2**-992), -63) + self.assertEqual(exponent_mapping.map_to_index(2**-991), -62) + self.assertEqual(exponent_mapping.map_to_index(2**-977), -62) + self.assertEqual(exponent_mapping.map_to_index(2**-976), -62) + self.assertEqual(exponent_mapping.map_to_index(2**-975), -61) + + def test_exponent_index_mat(self): + + for scale in range( + ExponentMapping._min_scale, ExponentMapping._max_scale + ): + exponent_mapping = ExponentMapping(scale) + + index = exponent_mapping.map_to_index(MAX_NORMAL_VALUE) + + max_index = ((MAX_NORMAL_EXPONENT + 1) >> -scale) - 1 + + self.assertEqual(index, max_index) + + boundary = exponent_mapping.get_lower_boundary(index) + + self.assertEqual(boundary, rounded_boundary(scale, max_index)) + + with self.assertRaises(Exception): + exponent_mapping.get_lower_boundary(index + 1) + + @mark.skipif( + version_info < (3, 9), + reason="math.nextafter is only available for Python >= 3.9", + ) + def test_exponent_index_mint(self): + for scale in range( + ExponentMapping._min_scale, ExponentMapping._max_scale + 1 + ): + exponent_mapping = ExponentMapping(scale) + + min_index = exponent_mapping.map_to_index(MIN_NORMAL_VALUE) + boundary = exponent_mapping.get_lower_boundary(min_index) + + correct_min_index = MIN_NORMAL_EXPONENT >> -scale + + if MIN_NORMAL_EXPONENT % (1 << -scale) == 0: + correct_min_index -= 1 + + # We do not check for correct_min_index to be greater than the + # smallest integer because the smallest integer in Python is -inf. + + self.assertEqual(correct_min_index, min_index) + + correct_boundary = rounded_boundary(scale, correct_min_index) + + self.assertEqual(correct_boundary, boundary) + self.assertGreater( + rounded_boundary(scale, correct_min_index + 1), boundary + ) + + self.assertEqual( + correct_min_index, + exponent_mapping.map_to_index(MIN_NORMAL_VALUE / 2), + ) + self.assertEqual( + correct_min_index, + exponent_mapping.map_to_index(MIN_NORMAL_VALUE / 3), + ) + self.assertEqual( + correct_min_index, + exponent_mapping.map_to_index(MIN_NORMAL_VALUE / 100), + ) + self.assertEqual( + correct_min_index, exponent_mapping.map_to_index(2**-1050) + ) + self.assertEqual( + correct_min_index, exponent_mapping.map_to_index(2**-1073) + ) + self.assertEqual( + correct_min_index, + exponent_mapping.map_to_index(1.1 * (2**-1073)), + ) + self.assertEqual( + correct_min_index, exponent_mapping.map_to_index(2**-1074) + ) + + with self.assertRaises(MappingUnderflowError): + exponent_mapping.get_lower_boundary(min_index - 1) + + self.assertEqual( + exponent_mapping.map_to_index( + nextafter(MIN_NORMAL_VALUE, inf) + ), + MIN_NORMAL_EXPONENT >> -scale, + ) diff --git a/opentelemetry-sdk/tests/metrics/exponential_histogram/test_ieee_754.py b/opentelemetry-sdk/tests/metrics/exponential_histogram/test_ieee_754.py new file mode 100644 index 00000000000..8ac6df8b61b --- /dev/null +++ b/opentelemetry-sdk/tests/metrics/exponential_histogram/test_ieee_754.py @@ -0,0 +1,92 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import TestCase + +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.ieee_754 import ( + get_ieee_754_64_binary, +) + + +class TestExponential(TestCase): + def test_get_ieee_754_64_binary(self): + """ + Bit 0: 1 for negative values, 0 for positive values + Bits 1 - 11: exponent, subtract 1023 from it to get the actual value + Bits 12 - 63: mantissa, a leading 1 is implicit + """ + + # 0 + # 10000000001 == 1025 -> 1025 - 1023 == 2 + # 0000000000000000000000000000000000000000000000000000 + + result = get_ieee_754_64_binary(4.0) + + self.assertEqual(result["sign"], "0") + self.assertEqual(result["exponent"], "10000000001") + self.assertEqual( + result["mantissa"], + "0000000000000000000000000000000000000000000000000000", + ) + self.assertEqual(result["decimal"], "4") + + result = get_ieee_754_64_binary(4.5) + + self.assertEqual(result["sign"], "0") + self.assertEqual(result["exponent"], "10000000001") + self.assertEqual( + result["mantissa"], + "0010000000000000000000000000000000000000000000000000", + ) + self.assertEqual(result["decimal"], "4.500") + + result = get_ieee_754_64_binary(-4.5) + + self.assertEqual(result["sign"], "1") + self.assertEqual(result["exponent"], "10000000001") + self.assertEqual( + result["mantissa"], + "0010000000000000000000000000000000000000000000000000", + ) + self.assertEqual(result["decimal"], "-4.500") + + result = get_ieee_754_64_binary(0.0) + + self.assertEqual(result["sign"], "0") + self.assertEqual(result["exponent"], "00000000000") + self.assertEqual( + result["mantissa"], + "0000000000000000000000000000000000000000000000000000", + ) + self.assertEqual(result["decimal"], "0") + + result = get_ieee_754_64_binary(-0.0) + + self.assertEqual(result["sign"], "1") + self.assertEqual(result["exponent"], "00000000000") + self.assertEqual( + result["mantissa"], + "0000000000000000000000000000000000000000000000000000", + ) + self.assertEqual(result["decimal"], "-0") + + result = get_ieee_754_64_binary(4.3) + + self.assertEqual(result["sign"], "0") + self.assertEqual(result["exponent"], "10000000001") + self.assertEqual( + result["mantissa"], + "0001001100110011001100110011001100110011001100110011", + ) + self.assertEqual(result["decimal"], "4.299999999999999822364316064") diff --git a/opentelemetry-sdk/tests/metrics/exponential_histogram/test_logarithm_mapping.py b/opentelemetry-sdk/tests/metrics/exponential_histogram/test_logarithm_mapping.py new file mode 100644 index 00000000000..5230166d3e9 --- /dev/null +++ b/opentelemetry-sdk/tests/metrics/exponential_histogram/test_logarithm_mapping.py @@ -0,0 +1,226 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from math import sqrt +from unittest import TestCase + +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.errors import ( + MappingOverflowError, + MappingUnderflowError, +) +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.ieee_754 import ( + MAX_NORMAL_EXPONENT, + MAX_NORMAL_VALUE, + MIN_NORMAL_EXPONENT, + MIN_NORMAL_VALUE, +) +from opentelemetry.sdk.metrics._internal.exponential_histogram.mapping.logarithm_mapping import ( + LogarithmMapping, +) + + +def rounded_boundary(scale: int, index: int) -> float: + + # This is implemented in this way to avoid using a third-party bigfloat + # package. The Go implementation uses a bigfloat package that is part of + # their standard library. The assumption here is that the smallest float + # available in Python is 2 ** -1022 (from sys.float_info.min). + while scale > 0: + if index < -1022: + index /= 2 + scale -= 1 + else: + break + + result = 2**index + + for _ in range(scale, 0, -1): + result = sqrt(result) + + return result + + +class TestLogarithmMapping(TestCase): + def assertInEpsilon(self, first, second, epsilon): + self.assertLessEqual(first, (second * (1 + epsilon))) + self.assertGreaterEqual(first, (second * (1 - epsilon))) + + def test_invalid_scale(self): + with self.assertRaises(Exception): + LogarithmMapping(-1) + + def test_logarithm_mapping_scale_one(self): + + # The exponentiation factor for this logarithm exponent histogram + # mapping is square_root(2). + # Scale 1 means 1 division between every power of two, having + # a factor sqare_root(2) times the lower boundary. + logarithm_exponent_histogram_mapping = LogarithmMapping(1) + + self.assertEqual(logarithm_exponent_histogram_mapping.scale, 1) + + # Note: Do not test exact boundaries, with the exception of + # 1, because we expect errors in that case (e.g., + # MapToIndex(8) returns 5, an off-by-one. See the following + # test. + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(15), 7 + ) + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(9), 6 + ) + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(7), 5 + ) + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(5), 4 + ) + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(3), 3 + ) + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(2.5), 2 + ) + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(1.5), 1 + ) + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(1.2), 0 + ) + # This one is actually an exact test + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(1), -1 + ) + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(0.75), -1 + ) + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(0.55), -2 + ) + self.assertEqual( + logarithm_exponent_histogram_mapping.map_to_index(0.45), -3 + ) + + def test_logarithm_boundary(self): + + for scale in [1, 2, 3, 4, 10, 15]: + logarithm_exponent_histogram_mapping = LogarithmMapping(scale) + + for index in [-100, -10, -1, 0, 1, 10, 100]: + + lower_boundary = ( + logarithm_exponent_histogram_mapping.get_lower_boundary( + index + ) + ) + + mapped_index = ( + logarithm_exponent_histogram_mapping.map_to_index( + lower_boundary + ) + ) + + self.assertLessEqual(index - 1, mapped_index) + self.assertGreaterEqual(index, mapped_index) + + self.assertInEpsilon( + lower_boundary, rounded_boundary(scale, index), 1e-9 + ) + + def test_logarithm_index_max(self): + + for scale in range( + LogarithmMapping._min_scale, LogarithmMapping._max_scale + 1 + ): + logarithm_mapping = LogarithmMapping(scale) + + index = logarithm_mapping.map_to_index(MAX_NORMAL_VALUE) + + max_index = ((MAX_NORMAL_EXPONENT + 1) << scale) - 1 + + # We do not check for max_index to be lesser than the + # greatest integer because the greatest integer in Python is inf. + + self.assertEqual(index, max_index) + + boundary = logarithm_mapping.get_lower_boundary(index) + + base = logarithm_mapping.get_lower_boundary(1) + + self.assertLess(boundary, MAX_NORMAL_VALUE) + + self.assertInEpsilon( + (MAX_NORMAL_VALUE - boundary) / boundary, base - 1, 1e-6 + ) + + with self.assertRaises(MappingOverflowError): + logarithm_mapping.get_lower_boundary(index + 1) + + with self.assertRaises(MappingOverflowError): + logarithm_mapping.get_lower_boundary(index + 2) + + def test_logarithm_index_min(self): + for scale in range( + LogarithmMapping._min_scale, LogarithmMapping._max_scale + 1 + ): + logarithm_mapping = LogarithmMapping(scale) + + min_index = logarithm_mapping.map_to_index(MIN_NORMAL_VALUE) + + correct_min_index = (MIN_NORMAL_EXPONENT << scale) - 1 + self.assertEqual(min_index, correct_min_index) + + correct_mapped = rounded_boundary(scale, correct_min_index) + self.assertLess(correct_mapped, MIN_NORMAL_VALUE) + + correct_mapped_upper = rounded_boundary( + scale, correct_min_index + 1 + ) + self.assertEqual(correct_mapped_upper, MIN_NORMAL_VALUE) + + mapped = logarithm_mapping.get_lower_boundary(min_index + 1) + + self.assertInEpsilon(mapped, MIN_NORMAL_VALUE, 1e-6) + + self.assertEqual( + logarithm_mapping.map_to_index(MIN_NORMAL_VALUE / 2), + correct_min_index, + ) + self.assertEqual( + logarithm_mapping.map_to_index(MIN_NORMAL_VALUE / 3), + correct_min_index, + ) + self.assertEqual( + logarithm_mapping.map_to_index(MIN_NORMAL_VALUE / 100), + correct_min_index, + ) + self.assertEqual( + logarithm_mapping.map_to_index(2**-1050), correct_min_index + ) + self.assertEqual( + logarithm_mapping.map_to_index(2**-1073), correct_min_index + ) + self.assertEqual( + logarithm_mapping.map_to_index(1.1 * 2**-1073), + correct_min_index, + ) + self.assertEqual( + logarithm_mapping.map_to_index(2**-1074), correct_min_index + ) + + mapped_lower = logarithm_mapping.get_lower_boundary(min_index) + self.assertInEpsilon(correct_mapped, mapped_lower, 1e-6) + + with self.assertRaises(MappingUnderflowError): + logarithm_mapping.get_lower_boundary(min_index - 1)