Failed Conditions
Branch master (876446)
by Jordan
08:56
created

RandomProvider::randomDecimal()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 31
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 4.0466

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 7
nc 3
nop 2
dl 0
loc 31
ccs 6
cts 7
cp 0.8571
crap 4.0466
rs 10
c 1
b 0
f 0
1
<?php
2
3
namespace Samsara\Fermat\Provider;
4
5
use Exception;
6
use JetBrains\PhpStorm\ExpectedValues;
7
use JetBrains\PhpStorm\Pure;
8
use Samsara\Exceptions\UsageError\IntegrityConstraint;
9
use Samsara\Exceptions\SystemError\LogicalError\IncompatibleObjectState;
10
use Samsara\Exceptions\UsageError\OptionalExit;
11
use Samsara\Fermat\Numbers;
12
use Samsara\Fermat\Types\Base\Interfaces\Numbers\DecimalInterface;
13
use Samsara\Fermat\Values\ImmutableDecimal;
14
15
class RandomProvider
16
{
17
18
    const MODE_ENTROPY = 1;
19
    const MODE_SPEED = 2;
20
21
    /** @noinspection PhpDocMissingThrowsInspection */
22
    /**
23
     * @param int|string|DecimalInterface $min
24
     * @param int|string|DecimalInterface $max
25
     * @param int $mode
26
     * @return ImmutableDecimal
27
     * @throws IntegrityConstraint
28
     * @throws OptionalExit
29
     * @throws IncompatibleObjectState
30
     */
31 9
    #[Pure]
32
    public static function randomInt(
33
        int|string|DecimalInterface $min,
34
        int|string|DecimalInterface $max,
35
        #[ExpectedValues([self::MODE_ENTROPY, self::MODE_SPEED])]
36
        int $mode = self::MODE_ENTROPY
37
    ): ImmutableDecimal
38
    {
39 9
        $minDecimal = Numbers::makeOrDont(Numbers::IMMUTABLE, $min);
40 9
        $maxDecimal = Numbers::makeOrDont(Numbers::IMMUTABLE, $max);
41
42
        /**
43
         * We want to prevent providing non-integer values for min and max, even in cases where
44
         * the supplied value is a string or a DecimalInterface.
45
         */
46 9
        if ($minDecimal->isFloat() || $maxDecimal->isFloat()) {
47
            throw new IntegrityConstraint(
48
                'Random integers cannot be generated with boundaries which are floats',
49
                'Provide only whole number, integer values for min and max.',
50
                'An attempt was made to generate a random integer with boundaries which are non-integers. Min Provided: '.$min->getValue().' -- Max Provided: '.$max->getValue()
51
            );
52
        }
53
54
        /**
55
         * Because of optimistic optimizing with the rand() and random_int() functions, we do
56
         * need the arguments to be provided in the correct order.
57
         */
58 9
        if ($minDecimal->isGreaterThan($maxDecimal)) {
59
            throw new IntegrityConstraint(
60
                'Minimum is larger than maximum.',
61
                'Please provide your arguments in the correct order.',
62
                'The supplied minimum value for randomInt() was greater than the supplied maximum value.'
63
            );
64
        }
65
66
        /**
67
         * For some applications it might be better to throw an exception here, however it
68
         * would probably be hard to recover in most applications from a situation which
69
         * resulted in this situation.
70
         *
71
         * So instead we will trigger a language level warning and return the only valid
72
         * value for the parameters given.
73
         */
74 9
        if ($minDecimal->isEqual($maxDecimal)) {
75 1
            trigger_error(
76 1
                'Attempted to get a random value for a range of no size, with minimum of '.$minDecimal->getValue().' and maximum of '.$maxDecimal->getValue(),
77 1
                E_USER_WARNING
78
            );
79
80
            return $minDecimal;
81
        }
82
83 8
        if ($minDecimal->isLessThanOrEqualTo(PHP_INT_MAX) && $minDecimal->isGreaterThanOrEqualTo(PHP_INT_MIN)) {
84
            /** @noinspection PhpUnhandledExceptionInspection */
85 8
            $min = $minDecimal->asInt();
86
        }
87
88 8
        if ($maxDecimal->isLessThanOrEqualTo(PHP_INT_MAX) && $maxDecimal->isGreaterThanOrEqualTo(PHP_INT_MIN)) {
89
            /** @noinspection PhpUnhandledExceptionInspection */
90 7
            $max = $maxDecimal->asInt();
91
        }
92
93 8
        if (is_int($min) && is_int($max)) {
94 7
            if ($mode == self::MODE_ENTROPY || $max > getrandmax() || $max < 0 || $min < 0) {
95
                /**
96
                 * The random_int() function is cryptographically secure, and takes somewhere on the order
97
                 * of 15 times as long to execute as rand(). However, rand() also has a smaller range than
98
                 * the entire PHP integer size, so there are some situations where we need to use this
99
                 * function even if MODE_SPEED is selected.
100
                 *
101
                 * In those cases, random_int() is still faster than calls to random_bytes() and manual
102
                 * masking.
103
                 */
104
                try {
105 7
                    $num = random_int($min, $max);
106 7
                    return new ImmutableDecimal($num);
107
                } catch (Exception $e) {
108
                    throw new OptionalExit(
109
                        'System error from random_bytes().',
110
                        'Ensure your system is configured correctly.',
111
                        'A call to random_bytes() threw a system level exception. Most often this is due to a problem with entropy sources in your configuration. Original exception message: ' . $e->getMessage()
112
                    );
113
                }
114 2
            } elseif ($mode == self::MODE_SPEED) {
115
                /**
116
                 * If it is possible to do so with the range given and the program has indicated that it
117
                 * would prefer speed over true randomness in the result, then we will use the deterministic
118
                 * pseudo-random function rand() as it is faster to reach completion.
119
                 */
120 2
                $num = rand($min, $max);
121 2
                return new ImmutableDecimal($num);
122
            } else {
123
                throw new IntegrityConstraint(
124
                    'Mode on random functions must be an implemented mode.',
125
                    'Choose modes using the class constants.',
126
                    'A mode was provided to randomInt() that does not correspond to any implementation. Please only use the class constants for selecting the mode.'
127
                );
128
            }
129
        } else {
130 1
            $two = Numbers::make(Numbers::IMMUTABLE, 2, 0);
131
132
            /**
133
             * We only need to request enough bytes to find a number within the range, since we
134
             * will be adding the minimum value to it at the end.
135
             */
136
            /** @noinspection PhpUnhandledExceptionInspection */
137 1
            $range = $maxDecimal->subtract($minDecimal);
138
            /** @noinspection PhpUnhandledExceptionInspection */
139 1
            $bitsNeeded = $range->ln(1)->divide($two->ln(1), 1)->floor()->add(1);
0 ignored issues
show
Bug introduced by
The method ln() does not exist on Samsara\Fermat\Values\MutableFraction. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

139
            $bitsNeeded = $range->ln(1)->divide($two->/** @scrutinizer ignore-call */ ln(1), 1)->floor()->add(1);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method floor() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of Samsara\Fermat\Types\Bas...Numbers\NumberInterface such as Samsara\Fermat\Types\Bas...umbers\DecimalInterface or Samsara\Fermat\Types\Decimal. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

139
            $bitsNeeded = $range->ln(1)->divide($two->ln(1), 1)->/** @scrutinizer ignore-call */ floor()->add(1);
Loading history...
Bug introduced by
The method ln() does not exist on Samsara\Fermat\Values\ImmutableFraction. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

139
            $bitsNeeded = $range->ln(1)->divide($two->/** @scrutinizer ignore-call */ ln(1), 1)->floor()->add(1);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method floor() does not exist on Samsara\Fermat\Types\Bas...mbers\FractionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

139
            $bitsNeeded = $range->ln(1)->divide($two->ln(1), 1)->/** @scrutinizer ignore-call */ floor()->add(1);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
140 1
            $bytesNeeded = $bitsNeeded->divide(8)->ceil();
0 ignored issues
show
Bug introduced by
The method ceil() does not exist on Samsara\Fermat\Types\Bas...mbers\FractionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

140
            $bytesNeeded = $bitsNeeded->divide(8)->/** @scrutinizer ignore-call */ ceil();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method ceil() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of Samsara\Fermat\Types\Bas...Numbers\NumberInterface such as Samsara\Fermat\Types\Bas...umbers\DecimalInterface or Samsara\Fermat\Types\Decimal. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

140
            $bytesNeeded = $bitsNeeded->divide(8)->/** @scrutinizer ignore-call */ ceil();
Loading history...
141
142
            do {
143
                try {
144
                    /**
145
                     * Returns random bytes based on sources of entropy within the system.
146
                     *
147
                     * For documentation on these sources please see:
148
                     *
149
                     * https://www.php.net/manual/en/function.random-bytes.php
150
                     */
151 1
                    $entropyBytes = random_bytes($bytesNeeded->asInt());
152 1
                    $baseTwoBytes = '';
153 1
                    for($i = 0; $i < strlen($entropyBytes); $i++){
154 1
                        $baseTwoBytes .= decbin( ord( $entropyBytes[$i] ) );
155
                    }
156
                } catch (Exception $e) {
157
                    throw new OptionalExit(
158
                        'System error from random_bytes().',
159
                        'Ensure your system is configured correctly.',
160
                        'A call to random_bytes() threw a system level exception. Most often this is due to a problem with entropy sources in your configuration. Original exception message: ' . $e->getMessage()
161
                    );
162
                }
163
164 1
                $randomValue = Numbers::make(
165 1
                    type: Numbers::IMMUTABLE,
166 1
                    value: $baseTwoBytes,
167 1
                    base: 2
168
                );
169
170
                /**
171
                 * @var ImmutableDecimal $num
172
                 *
173
                 * Since the number of digits is equal to the bits needed, but random_bytes() only
174
                 * returns in chunks of 8 bits (duh, bytes), we can substr() from the right to
175
                 * select only the correct number of digits by multiplying the number of bits
176
                 * needed by -1 and using that as the starting point.
177
                 */
178 1
                $num = Numbers::make(
179 1
                    type: Numbers::IMMUTABLE,
180 1
                    value: substr($randomValue->getValue(), $bitsNeeded->multiply(-1)->asInt()),
0 ignored issues
show
Bug introduced by
The method asInt() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of Samsara\Fermat\Types\Bas...Numbers\NumberInterface such as Samsara\Fermat\Types\Bas...umbers\DecimalInterface or Samsara\Fermat\Types\Decimal. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

180
                    value: substr($randomValue->getValue(), $bitsNeeded->multiply(-1)->/** @scrutinizer ignore-call */ asInt()),
Loading history...
Bug introduced by
The method asInt() does not exist on Samsara\Fermat\Types\Bas...mbers\FractionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

180
                    value: substr($randomValue->getValue(), $bitsNeeded->multiply(-1)->/** @scrutinizer ignore-call */ asInt()),

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
181 1
                    base: 2
182 1
                )->convertToBase(10);
0 ignored issues
show
Bug introduced by
The method convertToBase() does not exist on Samsara\Fermat\Values\MutableFraction. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

182
                )->/** @scrutinizer ignore-call */ convertToBase(10);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method convertToBase() does not exist on Samsara\Fermat\Values\ImmutableFraction. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

182
                )->/** @scrutinizer ignore-call */ convertToBase(10);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
183 1
            } while ($num->isGreaterThan($range));
184
            /**
185
             * It is strictly speaking possible for this to loop infinitely. In the worst case
186
             * scenario where 50% of possible values are invalid, it takes 7 loops for there to
187
             * be a less than a 1% chance of still not having an answer.
188
             *
189
             * After only 10 loops the chance is less than 1/1000.
190
             */
191
192
            /**
193
             * Add the minimum since we effectively subtracted it by finding a random number
194
             * bounded between 0 and range. If our requested range included negative numbers,
195
             * this operation will also return those values into our data by effectively
196
             * shifting the result window.
197
             */
198
            /** @noinspection PhpUnhandledExceptionInspection */
199 1
            return $num->add($minDecimal);
200
        }
201
    }
202
203
    /**
204
     * @param int $scale
205
     * @param int $mode
206
     * @return ImmutableDecimal
207
     * @throws IntegrityConstraint
208
     * @throws OptionalExit
209
     * @throws IncompatibleObjectState
210
     */
211 2
    #[Pure]
212
    public static function randomDecimal(
213
        int $scale = 10,
214
        #[ExpectedValues([self::MODE_ENTROPY, self::MODE_SPEED])]
215
        int $mode = self::MODE_ENTROPY
216
    ): ImmutableDecimal
217
    {
218
        /**
219
         * Select the min and max as if we were looking for the decimal part as an integer.
220
         */
221 2
        $min = new ImmutableDecimal(0);
222 2
        $max = new ImmutableDecimal(str_pad('1', $scale+1, '0', STR_PAD_RIGHT));
223
224
        /**
225
         * This allows us to utilize the same randomInt() function.
226
         */
227 2
        $randomValue = self::randomInt($min, $max, $mode);
228
229
        /**
230
         * If the random value exactly equals our min or max, that means we need to return
231
         * either 1 or 0.
232
         */
233 2
        if ($randomValue->isEqual($min) || $randomValue->isEqual($max)) {
234
            return $randomValue->isPositive() ? new ImmutableDecimal(1) : $min;
235
        }
236
237
        /**
238
         * In all other cases we need to reformat our integer as being the decimal portion
239
         * of our number at the given scale.
240
         */
241 2
        return new ImmutableDecimal('0.'.str_pad($randomValue->getValue(), $scale, '0', STR_PAD_LEFT));
242
    }
243
244
    /** @noinspection PhpDocMissingThrowsInspection */
245
    /**
246
     * @param int|string|DecimalInterface $min
247
     * @param int|string|DecimalInterface $max
248
     * @param int $scale
249
     * @param int $mode
250
     * @return ImmutableDecimal
251
     * @throws IntegrityConstraint
252
     * @throws OptionalExit
253
     * @throws IncompatibleObjectState
254
     */
255 3
    #[Pure]
256
    public static function randomReal(
257
        int|string|DecimalInterface $min,
258
        int|string|DecimalInterface $max,
259
        int $scale,
260
        #[ExpectedValues([self::MODE_ENTROPY, self::MODE_SPEED])]
261
        int $mode = self::MODE_ENTROPY
262
    ): ImmutableDecimal
263
    {
264 3
        $min = new ImmutableDecimal($min);
265 3
        $max = new ImmutableDecimal($max);
266
267 3
        if ($min->isEqual($max)) {
268
            trigger_error(
269
                'Attempted to get a random value for a range of no size, with minimum of '.$min->getValue().' and maximum of '.$max->getValue(),
270
                E_USER_WARNING
271
            );
272
273
            return $min;
274
        }
275
276
        /**
277
         * We do this because randomDecimal() can return 1, so if max is a natural number we need to
278
         * remove it from the result set. Otherwise, we would be grabbing extra values and be shifting
279
         * them to somewhere else in the result set, which skews the relative probabilities.
280
         */
281 3
        if ($max->isNatural()) {
282
            /** @noinspection PhpUnhandledExceptionInspection */
283
            $maxIntRange = $max->subtract(1);
284
        } else {
285 3
            $maxIntRange = $max->floor();
286
        }
287
288 3
        if (!$min->floor()->isEqual($maxIntRange)) {
289 2
            $intPart = self::randomInt($min->floor(), $maxIntRange, $mode);
290 2
            $repeatProbability = Numbers::makeZero();
291
292
            /**
293
             * If min and max aren't bounded by the same integers, then we need to adjust the likelihood
294
             * of an integer on the ends of the range being selected according to the percentage of
295
             * numbers within that range which are available.
296
             */
297 2
            if ($min->ceil()->isEqual($max->floor())) {
298
                /**
299
                 * This is a special case where min and max are less than 1 apart, but they straddle an
300
                 * integer. In this case, we want to consider the relative likelihood, instead of the
301
                 * portion of real numbers available.
302
                 */
303 1
                $minCeil = $min->ceil();
304 1
                $minRepeat = $minCeil->subtract($min);
305 1
                $maxFloor = $max->floor();
306
                /** @noinspection PhpUnhandledExceptionInspection */
307 1
                $maxRepeat = $max->subtract($maxFloor);
308 1
                $one = Numbers::makeOne(10);
309
310
                /** @noinspection PhpUnhandledExceptionInspection */
311 1
                $repeatProbability = $one->subtract($maxRepeat->divide($minRepeat, 10));
312 1
            } elseif ($intPart->isEqual($min->floor())) {
313
                /**
314
                 * In this case, the integer includes the min. Since it's possible that not all reals
315
                 * in this range are actually available to choose from, the likelihood that this integer
316
                 * was chosen relative to any other integer in the range can be adjusted by making a
317
                 * recursive call with probability X, where X is min - floor(min).
318
                 */
319 1
                $minFloor = $min->floor();
320
                /** @noinspection PhpUnhandledExceptionInspection */
321 1
                $repeatProbability = $min->subtract($minFloor);
322 1
            } elseif ($intPart->isEqual($max->floor())) {
323
                /**
324
                 * In this case, the integer includes the max. Since it's possible that not all reals
325
                 * in this range are actually available to choose from, the likelihood that this integer
326
                 * was chosen relative to any other integer in the range can be adjusted by making a
327
                 * recursive call with probability X, where X is ceil(max) - max.
328
                 */
329 1
                $maxCeil = $max->ceil();
330 1
                $repeatProbability = $maxCeil->subtract($max);
331
            }
332
333
            /**
334
             * This will never be true unless one of the special cases above occurred. We use short circuiting
335
             * to prevent a needless additional random generation in situations where there is zero probability
336
             * adjustment.
337
             */
338 2
            if ($repeatProbability->isGreaterThan(0) && $repeatProbability->isGreaterThan(self::randomDecimal(10, $mode))) {
339 2
                return self::randomReal($min, $max, $scale, $mode);
340
            }
341
        } else {
342
            /**
343
             * In the case where min and max are bounded by the same integers, we can just set the integer
344
             * part to floor(min) without any further calculation. All of the randomness of the value will
345
             * come from the decimal part.
346
             */
347 1
            $intPart = $min->floor();
348
        }
349
350 3
        if (!$intPart->isEqual($max->floor()) && !$intPart->isEqual($min->floor())) {
351
            /**
352
             * Because we know at this point that min and max are not equal prior to the conditions in
353
             * this statement, we can be certain that the entire decimal range is available for selection
354
             * if it passes these checks.
355
             *
356
             * The situations in which the entire decimal is a valid part of the result set are all covered
357
             * by checking that intPart isn't equal to the floor of either min or max, since those are the only
358
             * two integers which have bounded decimal ranges.
359
             */
360 1
            $decPart = self::randomDecimal($scale, $mode);
361
        } else {
362 3
            if ($min->isNatural() || $intPart->isGreaterThan($min->floor())) {
363
                /**
364
                 * The greater than check is also true any time min is a natural number (integer), however the check
365
                 * for min being an integer is much faster, so we're taking advantage of short circuiting.
366
                 */
367 2
                $minDecimal = Numbers::makeZero();
368
            } else {
369
                /**
370
                 * The min is guaranteed to have a decimal portion here, since we already checked if it's natural.
371
                 *
372
                 * First we use string manipulation to extract the decimal portion as an integer value, the we right
373
                 * pad with zeroes to make sure that the entire scale is part of the valid result set.
374
                 */
375 3
                $minDecimal = substr($min->getValue(), strpos($min->getValue(), '.') + 1);
376 3
                $minDecimal = str_pad($minDecimal, $scale, '0', STR_PAD_RIGHT);
377
            }
378
379 3
            if ($intPart->isLessThan($max->floor())) {
380
                /**
381
                 * We cannot take advantage of a more efficient check for the top end of the range, so the
382
                 * less than check is all we need.
383
                 */
384 2
                $maxDecimal = str_pad('1', $scale + 1, '0', STR_PAD_RIGHT);
385
            } else {
386
                /**
387
                 * The max value is guaranteed to have a decimal portion here since we excluded max being
388
                 * a natural number and part of the result set for intPart.
389
                 *
390
                 * First we use string manipulation to extract the decimal portion as an integer value, the we right
391
                 * pad with zeroes to make sure that the entire scale is part of the valid result set.
392
                 */
393 3
                $maxDecimal = substr($max->getValue(), strpos($max->getValue(), '.')+1);
394 3
                $maxDecimal = str_pad($maxDecimal, $scale, '0', STR_PAD_RIGHT);
395
            }
396
397
            /**
398
             * Now that we have the correct bounds for the integers we're bounded by, figure out what the decimal
399
             * portion of the random number is by utilizing randomInt().
400
             */
401 3
            $decPartAsInt = self::randomInt($minDecimal, $maxDecimal, $mode);
402
403 3
            if ($decPartAsInt->isEqual($maxDecimal) && strlen($maxDecimal) > $scale) {
404
                /**
405
                 * In the case where maxDecimal was returned by randomInt, we want to specifically translate
406
                 * that to 1 instead of treating it as a decimal value. But that's only the case if maxDecimal
407
                 * was larger than our scale.
408
                 *
409
                 * This is another case of us using short circuiting on a more efficient call.
410
                 */
411 1
                $decPart = Numbers::makeOne($scale);
412
            } else {
413
                /**
414
                 * In this section we know with certainty that the result of randomInt represents a decimal value
415
                 * that we can simply append as a string with padding to ensure correct scale.
416
                 */
417 3
                $decPart = new ImmutableDecimal(
418 3
                    value: '0.'.str_pad($decPartAsInt->getValue(), $scale, '0', STR_PAD_LEFT),
419
                    scale: $scale
420
                );
421
            }
422
        }
423
424
        /**
425
         * Combine the integer and decimal portions of the random value.
426
         */
427
        /** @noinspection PhpUnhandledExceptionInspection */
428 3
        return $intPart->add($decPart);
429
    }
430
431
}