diff --git a/.github/workflows/test-analyze-tag-release.yml b/.github/workflows/test-analyze-tag-release.yml index 01203cd..868631e 100644 --- a/.github/workflows/test-analyze-tag-release.yml +++ b/.github/workflows/test-analyze-tag-release.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: [ '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] + php-version: [ '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] steps: - name: Checkout code @@ -26,6 +26,9 @@ jobs: coverage: pcov tools: composer:v2 + - name: Remove packages from composer.json + run: composer remove php-cs-fixer/shim phpstan/phpstan --dev + - name: Validate composer.json run: composer validate --strict diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 157e42b..38c3ae9 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -8,8 +8,14 @@ return (new Config()) ->setRiskyAllowed(false) ->setRules([ + '@PHP7x0Migration' => true, '@Symfony' => true, '@PhpCsFixer' => true, + 'nullable_type_declaration_for_default_null_value' => false, + 'phpdoc_no_empty_return' => false, + 'modifier_keywords' => [ + 'elements' => ['property', 'method'], + ], ]) // 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config ->setFinder( diff --git a/README.md b/README.md index 799c583..5e91933 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ It is **not** appropriate when an exact distinct count is required. ## Requirements -- **PHP 8.0, 8.1, 8.2, 8.3, 8.4 and 8.5** +- **PHP 7.0, 7.1, 7.2, 7.3, 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 and 8.5** - No external dependencies > **Note:** The `xxh3` and `xxh128` hash algorithms are available only when supported by your PHP version and build. If diff --git a/composer.json b/composer.json index c75506d..905b97b 100644 --- a/composer.json +++ b/composer.json @@ -17,12 +17,12 @@ } ], "require": { - "php": "^8.0" + "php": "^8.0|^7.0" }, "require-dev": { "php-cs-fixer/shim": "^3.95", "phpstan/phpstan": "^2.2", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6|^8.5|^6.5" }, "autoload": { "psr-4": { @@ -50,4 +50,4 @@ "sort-packages": true, "optimize-autoloader": true } -} \ No newline at end of file +} diff --git a/src/HyperLogLog.php b/src/HyperLogLog.php index 386a34e..4f9fffc 100644 --- a/src/HyperLogLog.php +++ b/src/HyperLogLog.php @@ -16,18 +16,22 @@ class HyperLogLog { /** @var int */ - private const TWO_POW_32 = 4_294_967_296; - private int $counterBits; + const TWO_POW_32 = 4294967296; - private string $hashAlgorithm; + /** @var int */ + private $counterBits; + + /** @var string */ + private $hashAlgorithm; - private int $m; + /** @var int */ + private $m; /** @var array Counters storing maximum rho values */ - private array $counters; + private $counters; /** @var bool Indicates if the selected hash algorithm produces less than 64 bits */ - private bool $needsPadding; + private $needsPadding; /** * HyperLogLog constructor. @@ -95,8 +99,10 @@ public function setCounters(array $counters): self * and leading zero count, and the register is updated with the maximum observed rho value. * * @param string $value element to insert + * + * @return void */ - public function add(string $value): void + public function add(string $value) { $hashString = $this->hash($value, $this->hashAlgorithm); @@ -179,7 +185,9 @@ public function merge(self $other): self } $this->counters = array_map( - static fn (int $counter, int $otherCounter): int => max($counter, $otherCounter), + static function (int $counter, int $otherCounter): int { + return max($counter, $otherCounter); + }, $this->counters, $other->counters ); @@ -221,17 +229,34 @@ public function alpha(int $m): float throw new \InvalidArgumentException('Invalid number of counters, $m must be greater than 0'); } - return match ($m) { - 2 => 0.46852874309841, - 4 => 0.56806457964166, - 8 => 0.63557660535301, - 16 => 0.673, - 32 => 0.697, - 64 => 0.709, - 128 => 0.71527049326382, - 256 => 0.71827259324955, - default => 0.7213 / (1 + 1.079 / $m), - }; + switch ($m) { + case 2: + return 0.46852874309841; + + case 4: + return 0.56806457964166; + + case 8: + return 0.63557660535301; + + case 16: + return 0.673; + + case 32: + return 0.697; + + case 64: + return 0.709; + + case 128: + return 0.71527049326382; + + case 256: + return 0.71827259324955; + + default: + return 0.7213 / (1 + 1.079 / $m); + } } /** diff --git a/tests/HyperLogLogTest.php b/tests/HyperLogLogTest.php index fb04b1a..a51577a 100644 --- a/tests/HyperLogLogTest.php +++ b/tests/HyperLogLogTest.php @@ -14,15 +14,33 @@ */ class HyperLogLogTest extends TestCase { - public function testConstructorSetsDefaultValues(): void + /** @var string */ + private $defaultHashAlgorithm = 'xxh3'; + + /** @var int */ + private $defaultCounterBits = 5; + + /** + * @param array $data + * @param int|string $dataName + */ + public function __construct(string $name = null, array $data = [], $dataName = '') { - $hashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; + parent::__construct($name, $data, $dataName); + + $this->defaultHashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; + } - $hll = new HyperLogLog(hashAlgorithm: $hashAlgorithm); + /** + * @return void + */ + public function testConstructorSetsDefaultValues() + { + $hll = new HyperLogLog($this->defaultCounterBits, $this->defaultHashAlgorithm); // Check if the default counter bits and hash algorithms are set $this->assertSame(5, $hll->getCounterBits()); - $this->assertSame($hashAlgorithm, $hll->getHashAlgorithm()); + $this->assertSame($this->defaultHashAlgorithm, $hll->getHashAlgorithm()); // m should be 2^5 = 32 $this->assertSame(32, $hll->getM()); @@ -31,7 +49,10 @@ public function testConstructorSetsDefaultValues(): void $this->assertCount(32, $hll->getCounters()); } - public function testConstructorSetsCustomValues(): void + /** + * @return void + */ + public function testConstructorSetsCustomValues() { $hll = new HyperLogLog(10, 'sha256'); @@ -43,18 +64,24 @@ public function testConstructorSetsCustomValues(): void $this->assertCount(1024, $hll->getCounters()); } - public function testConstructorFailsWithInvalidHashAlgorithm(): void + /** + * @return void + */ + public function testConstructorFailsWithInvalidHashAlgorithm() { // In PHP 8+, the hash() function throws a ValueError if the provided algorithm does not exist. $this->expectException(\InvalidArgumentException::class); new HyperLogLog(10, 'invalid_algo_123'); } - public function testGettersAndSetters(): void + /** + * @return void + */ + public function testGettersAndSetters() { $hashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; - $hll = new HyperLogLog(hashAlgorithm: $hashAlgorithm); + $hll = new HyperLogLog($this->defaultCounterBits, $this->defaultHashAlgorithm); // Create a mock state to inject into the instance $mockCounters = array_fill(0, 32, 1); @@ -64,7 +91,10 @@ public function testGettersAndSetters(): void $this->assertSame($mockCounters, $hll->getCounters()); } - public function testAddAndCountUniqueElements(): void + /** + * @return void + */ + public function testAddAndCountUniqueElements() { // Use sha256 to ensure universal test execution across different PHP environments $hll = new HyperLogLog(12, 'sha256'); @@ -81,7 +111,10 @@ public function testAddAndCountUniqueElements(): void $this->assertLessThan(5500, $estimate); } - public function testAddDuplicateElementsMaintainsCardinality(): void + /** + * @return void + */ + public function testAddDuplicateElementsMaintainsCardinality() { $hll = new HyperLogLog(10, 'sha256'); @@ -92,19 +125,25 @@ public function testAddDuplicateElementsMaintainsCardinality(): void $estimate = $hll->count(); // Adding the exact same string 1000 times should result in a total count of ~1 - $this->assertEqualsWithDelta(1.0, $estimate, 1.0); + $this->assertFloatEquals(1.0, $estimate, 1.0); } - public function testAddEmptyString(): void + /** + * @return void + */ + public function testAddEmptyString() { $hll = new HyperLogLog(10, 'sha256'); $hll->add(''); // An empty string is a valid element that should be processed and count as 1. - $this->assertEqualsWithDelta(1.0, $hll->count(), 1.0); + $this->assertFloatEquals(1.0, $hll->count(), 1.0); } - public function testAddWithShortHashAlgorithmTriggersPaddingCorrectly(): void + /** + * @return void + */ + public function testAddWithShortHashAlgorithmTriggersPaddingCorrectly() { // crc32 produces a 32-bit (4 bytes) hash. // This must trigger the internal right-padding with null bytes so unpack('J') doesn't fail. @@ -117,7 +156,10 @@ public function testAddWithShortHashAlgorithmTriggersPaddingCorrectly(): void $this->assertGreaterThan(1.0, $estimate); } - public function testCountTriggersSmallRangeCorrectionInternalBranch(): void + /** + * @return void + */ + public function testCountTriggersSmallRangeCorrectionInternalBranch() { $hll = new HyperLogLog(12, 'sha256'); // m = 4096 @@ -134,7 +176,10 @@ public function testCountTriggersSmallRangeCorrectionInternalBranch(): void $this->assertLessThan(15.0, $estimate); } - public function testCountTriggersLargeRangeCorrectionInternalBranch(): void + /** + * @return void + */ + public function testCountTriggersLargeRangeCorrectionInternalBranch() { $hll = new HyperLogLog(10, 'sha256'); // m = 1024 @@ -146,7 +191,7 @@ public function testCountTriggersLargeRangeCorrectionInternalBranch(): void $hll->setCounters($mockCounters); $estimate = $hll->count(); - $twoPow32 = 4_294_967_296; + $twoPow32 = 4294967296; // Verify that the value is a valid float and not a mathematically undefined result (NAN). $this->assertFalse(is_nan($estimate), 'The estimate must not be NAN.'); @@ -155,22 +200,28 @@ public function testCountTriggersLargeRangeCorrectionInternalBranch(): void $this->assertGreaterThan($twoPow32 / 30, $estimate); } - public function testTheoreticalErrorRateCalculation(): void + /** + * @return void + */ + public function testTheoreticalErrorRateCalculation() { $hashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; - $hll = new HyperLogLog(hashAlgorithm: $hashAlgorithm); + $hll = new HyperLogLog($this->defaultCounterBits, $this->defaultHashAlgorithm); // Theoretical error rate formula: 1.04 / sqrt(m) // 1.04 / sqrt(16) = 1.04 / 4 = 0.26 - $this->assertEqualsWithDelta(0.26, $hll->theoreticalErrorRate(16), 0.001); + $this->assertFloatEquals(0.26, $hll->theoreticalErrorRate(16), 0.001); } - public function testTheoreticalErrorRateThrowsExceptionForInvalidM(): void + /** + * @return void + */ + public function testTheoreticalErrorRateThrowsExceptionForInvalidM() { $hashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; - $hll = new HyperLogLog(hashAlgorithm: $hashAlgorithm); + $hll = new HyperLogLog($this->defaultCounterBits, $this->defaultHashAlgorithm); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid number of counters, $m must be greater than 0'); @@ -178,47 +229,59 @@ public function testTheoreticalErrorRateThrowsExceptionForInvalidM(): void $hll->theoreticalErrorRate(0); } - public function testMeasureError(): void + /** + * @return void + */ + public function testMeasureError() { $hashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; - $hll = new HyperLogLog(hashAlgorithm: $hashAlgorithm); + $hll = new HyperLogLog($this->defaultCounterBits, $this->defaultHashAlgorithm); // measureError simply returns the difference: estimate - real $this->assertSame(5, $hll->measureError(15, 10)); $this->assertSame(-2, $hll->measureError(8, 10)); } - public function testAlphaCalculationValues(): void + /** + * @return void + */ + public function testAlphaCalculationValues() { $hashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; - $hll = new HyperLogLog(hashAlgorithm: $hashAlgorithm); + $hll = new HyperLogLog($this->defaultCounterBits, $this->defaultHashAlgorithm); // Test exact predefined constants for m=2 and m=16 - $this->assertEqualsWithDelta(0.46852874309841, $hll->alpha(2), 0.0000001); - $this->assertEqualsWithDelta(0.673, $hll->alpha(16), 0.001); + $this->assertFloatEquals(0.46852874309841, $hll->alpha(2), 0.0000001); + $this->assertFloatEquals(0.673, $hll->alpha(16), 0.001); // Test the default fallback calculation for m >= 128 (e.g., 512) $expectedLargeAlpha = 0.7213 / (1 + 1.079 / 512); - $this->assertEqualsWithDelta($expectedLargeAlpha, $hll->alpha(512), 0.001); + $this->assertFloatEquals($expectedLargeAlpha, $hll->alpha(512), 0.001); } - public function testAlphaThrowsExceptionForInvalidM(): void + /** + * @return void + */ + public function testAlphaThrowsExceptionForInvalidM() { $hashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; - $hll = new HyperLogLog(hashAlgorithm: $hashAlgorithm); + $hll = new HyperLogLog($this->defaultCounterBits, $this->defaultHashAlgorithm); $this->expectException(\InvalidArgumentException::class); $hll->alpha(0); } - public function testEstimateThrowsExceptionForInvalidZ(): void + /** + * @return void + */ + public function testEstimateThrowsExceptionForInvalidZ() { $hashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; - $hll = new HyperLogLog(hashAlgorithm: $hashAlgorithm); + $hll = new HyperLogLog($this->defaultCounterBits, $this->defaultHashAlgorithm); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid harmonic mean, $Z must be positive'); @@ -227,22 +290,28 @@ public function testEstimateThrowsExceptionForInvalidZ(): void $hll->estimate(16, 0.0); } - public function testEstimateUsingLinearCounting(): void + /** + * @return void + */ + public function testEstimateUsingLinearCounting() { $hashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; - $hll = new HyperLogLog(hashAlgorithm: $hashAlgorithm); + $hll = new HyperLogLog($this->defaultCounterBits, $this->defaultHashAlgorithm); $m = 64; $v = 16; // 16 empty counters out of 64 // Linear Counting formula for small cardinalities: m * log(m / V) $expected = 64 * log(64 / 16); // 64 * log(4) ≈ 88.72 - $this->assertEqualsWithDelta($expected, $hll->estimateUsingLinearCounting($v, $m), 0.001); - $this->assertEqualsWithDelta($expected, $hll->estimateUsingSmallCardinalitiesApproach($v, $m), 0.001); + $this->assertFloatEquals($expected, $hll->estimateUsingLinearCounting($v, $m), 0.001); + $this->assertFloatEquals($expected, $hll->estimateUsingSmallCardinalitiesApproach($v, $m), 0.001); } - public function testMergeThrowsExceptionForMismatchedCounterBits(): void + /** + * @return void + */ + public function testMergeThrowsExceptionForMismatchedCounterBits() { $hll1 = new HyperLogLog(10, 'sha256'); $hll2 = new HyperLogLog(12, 'sha256'); @@ -253,7 +322,10 @@ public function testMergeThrowsExceptionForMismatchedCounterBits(): void $hll1->merge($hll2); } - public function testMergeThrowsExceptionForMismatchedHashAlgorithms(): void + /** + * @return void + */ + public function testMergeThrowsExceptionForMismatchedHashAlgorithms() { $hll1 = new HyperLogLog(10, 'sha256'); $hll2 = new HyperLogLog(10, 'sha1'); @@ -264,7 +336,10 @@ public function testMergeThrowsExceptionForMismatchedHashAlgorithms(): void $hll1->merge($hll2); } - public function testMergeUpdatesCountersWithMaxValues(): void + /** + * @return void + */ + public function testMergeUpdatesCountersWithMaxValues() { $hll1 = new HyperLogLog(5, 'sha256'); // m = 32 $hll2 = new HyperLogLog(5, 'sha256'); // m = 32 @@ -298,7 +373,10 @@ public function testMergeUpdatesCountersWithMaxValues(): void $this->assertSame($hll1, $result); } - public function testMergeEstimatesUnionOfTwoSetsCorrectly(): void + /** + * @return void + */ + public function testMergeEstimatesUnionOfTwoSetsCorrectly() { $hll1 = new HyperLogLog(12, 'sha256'); $hll2 = new HyperLogLog(12, 'sha256'); @@ -323,23 +401,52 @@ public function testMergeEstimatesUnionOfTwoSetsCorrectly(): void $this->assertLessThan(5500, $estimate); } - public function testEstimateUsingLargeCardinalitiesApproach(): void + /** + * @return void + */ + public function testEstimateUsingLargeCardinalitiesApproach() { $hashAlgorithm = PHP_VERSION >= 8100 ? 'xxh3' : 'sha256'; - $hll = new HyperLogLog(hashAlgorithm: $hashAlgorithm); + $hll = new HyperLogLog($this->defaultCounterBits, $this->defaultHashAlgorithm); - $rawEstimate = 3_000_000_000; - $twoPow32 = 4_294_967_296; + $rawEstimate = 3000000000; + $twoPow32 = 4294967296; // Large Cardinality formula to correct hash collision bias near 2^32: // -2^32 * log(1 - E / 2^32) $expected = -$twoPow32 * log(1 - $rawEstimate / $twoPow32); - $this->assertEqualsWithDelta( + $this->assertFloatEquals( $expected, $hll->estimateUsingLargeCardinalitiesApproach($rawEstimate), 0.001 ); } + + /** + * Custom assertion helper to support delta comparisons across all PHPUnit versions + * without conflicting with native static methods. + * + * @param float $expected The expected value + * @param float $actual The actual value to test + * @param float $delta The allowed margin of error + * + * @return void + */ + private function assertFloatEquals(float $expected, float $actual, float $delta) + { + $difference = abs($expected - $actual); + + $this->assertLessThanOrEqual( + $delta, + $difference, + sprintf( + 'Failed asserting that actual %s matches expected %s within a delta of %s.', + $actual, + $expected, + $delta + ) + ); + } }