From 7835de94062559e71663974125329a924ffceb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B5=D0=BD=D0=B4=D0=B5=D0=BB=D0=B5=D0=B2=20=D0=90?= =?UTF-8?q?=D1=80=D1=82=D1=91=D0=BC?= Date: Tue, 1 Sep 2026 09:22:20 +0300 Subject: [PATCH 1/5] fix(Tensor): normalize()/norm() never iterate over buffer values TensorBuffer implements ArrayAccess/Countable but not Iterator or IteratorAggregate. `foreach ($this->buffer as ...)` on such an object iterates its public properties instead of raising an error, which for TensorBuffer means zero iterations. As a result, norm()'s axis-reduction loop never accumulated anything and normalize() never divided any value, silently turning both into no-ops for real (non-scalar) tensors. Use toBufferArray() instead, matching the pattern already used by the axis === null branch in norm(). --- src/Tensor/Tensor.php | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Tensor/Tensor.php b/src/Tensor/Tensor.php index eb10625..2fe7062 100644 --- a/src/Tensor/Tensor.php +++ b/src/Tensor/Tensor.php @@ -820,25 +820,27 @@ public function normalize(int $p = 2, ?int $axis = null): static $norm = $result->norm($p, $axis, true); - foreach ($norm->buffer as $i => $value) { - $resultIndex = 0; + // TensorBuffer doesn't implement Iterator/IteratorAggregate, so foreach over it + // yields zero iterations; use the flattened logical values instead + foreach ($result->toBufferArray() as $i => $value) { + $normIndex = 0; $num = $i; - $resultMultiplier = 1; + $normMultiplier = 1; for ($j = $result->ndim() - 1; $j >= 0; --$j) { $size = $result->shape()[$j]; if ($j !== $axis) { $index = $num % $size; - $resultIndex += $index * $resultMultiplier; - $resultMultiplier *= $result->shape()[$j]; + $normIndex += $index * $normMultiplier; + $normMultiplier *= $result->shape()[$j]; } $num = floor($num / $size); } // Divide by normalized value - $result->buffer[$i] /= $norm->buffer[$resultIndex]; + $result->buffer[$i] /= $norm->buffer[$normIndex]; } return $result; @@ -873,8 +875,9 @@ public function norm(int $ord = 2, ?int $axis = null, bool $keepShape = false): // Create a new array to store the accumulated values $result = $this->zeros([count($this->buffer) / $this->shape()[$axis]]); - // Iterate over the data array - foreach ($this->buffer as $i => $value) { + // TensorBuffer doesn't implement Iterator/IteratorAggregate, so foreach over it + // yields zero iterations; use the flattened logical values instead + foreach ($this->toBufferArray() as $i => $value) { // Calculate the index in the resulting array $resultIndex = 0; $num = $i; @@ -893,7 +896,7 @@ public function norm(int $ord = 2, ?int $axis = null, bool $keepShape = false): } // Accumulate the value at the current index - $result[$resultIndex] += pow($this->buffer[$i], $ord); + $result[$resultIndex] += pow($value, $ord); } if ($ord === 1) { From 60be559f189ea1c45bfcee2f2a2ea9aad0f6e111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B5=D0=BD=D0=B4=D0=B5=D0=BB=D0=B5=D0=B2=20=D0=90?= =?UTF-8?q?=D1=80=D1=82=D1=91=D0=BC?= Date: Tue, 1 Sep 2026 09:22:34 +0300 Subject: [PATCH 2/5] fix(Tensor): norm() never applied the 1/ord root for L2 and other norms The condition was inverted: raising the accumulated sum to the power of 1/ord was gated on `$ord === 1`, where 1/1 = 1 makes it a no-op anyway. For the common case $ord = 2 (L2 norm) the square root was never taken, so norm() returned the sum of squares instead of the actual norm. --- src/Tensor/Tensor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tensor/Tensor.php b/src/Tensor/Tensor.php index 2fe7062..c01e23c 100644 --- a/src/Tensor/Tensor.php +++ b/src/Tensor/Tensor.php @@ -899,7 +899,7 @@ public function norm(int $ord = 2, ?int $axis = null, bool $keepShape = false): $result[$resultIndex] += pow($value, $ord); } - if ($ord === 1) { + if ($ord !== 1) { $result = $mo->op($result, '**', 1 / $ord); } From 1b1fc16217bd5cc6d38fc238bc3849b55f98cbd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B5=D0=BD=D0=B4=D0=B5=D0=BB=D0=B5=D0=B2=20=D0=90?= =?UTF-8?q?=D1=80=D1=82=D1=91=D0=BC?= Date: Tue, 1 Sep 2026 09:22:44 +0300 Subject: [PATCH 3/5] fix(Precompiled): avoid ord() deprecation on multi-byte characters mb_str_split() can return multi-byte graphemes (e.g. Cyrillic), and ord() on PHP 8.4+ is deprecated when given a string longer than one byte. Use ord($c[0]) as suggested by the deprecation notice itself, which preserves the previous (first-byte) behaviour. --- src/Normalizers/Precompiled.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Normalizers/Precompiled.php b/src/Normalizers/Precompiled.php index 72b9a90..20efaec 100644 --- a/src/Normalizers/Precompiled.php +++ b/src/Normalizers/Precompiled.php @@ -153,10 +153,10 @@ public function commonPrefixSearch($key): array $node_pos ^= $this->offset($unit); foreach (mb_str_split($key) as $c) { - if (ord($c) === 0) { + if (ord($c[0]) === 0) { break; } - $node_pos ^= ord($c); + $node_pos ^= ord($c[0]); $unit = $this->array[$node_pos]; if ($this->label($unit) !== mb_ord($c)) { return $results; From 92f3c16c44ab47e3172d06161c6344fc666f5ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B5=D0=BD=D0=B4=D0=B5=D0=BB=D0=B5=D0=B2=20=D0=90?= =?UTF-8?q?=D1=80=D1=82=D1=91=D0=BC?= Date: Tue, 1 Sep 2026 09:22:59 +0300 Subject: [PATCH 4/5] fix(Downloader): remove deprecated curl_close() calls curl_close() has been a no-op since PHP 8.0 (CurlHandle is closed by the garbage collector) and is deprecated since PHP 8.5. --- src/Utils/Downloader.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Utils/Downloader.php b/src/Utils/Downloader.php index 5e5cd1a..3e15002 100644 --- a/src/Utils/Downloader.php +++ b/src/Utils/Downloader.php @@ -110,7 +110,6 @@ public static function download(string $url, string $to, array $options = [], ?c if (curl_exec($curlHandle) === false) { $error = curl_error($curlHandle); - curl_close($curlHandle); fclose($headerHandle); fclose($bodyHandle); throw new \Exception("The \"$url\" file could not be downloaded: $error"); @@ -119,14 +118,11 @@ public static function download(string $url, string $to, array $options = [], ?c $statusCode = curl_getinfo($curlHandle, CURLINFO_RESPONSE_CODE); if ($statusCode < 200 || $statusCode >= 300) { - curl_close($curlHandle); fclose($headerHandle); fclose($bodyHandle); throw new \Exception("The \"$url\" file could not be downloaded: HTTP $statusCode"); } - curl_close($curlHandle); - rewind($headerHandle); $headers = stream_get_contents($headerHandle); From 71a6607e7bba265b8e0392d63692b95b863af788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B5=D0=BD=D0=B4=D0=B5=D0=BB=D0=B5=D0=B2=20=D0=90?= =?UTF-8?q?=D1=80=D1=82=D1=91=D0=BC?= Date: Tue, 1 Sep 2026 09:34:51 +0300 Subject: [PATCH 5/5] test(Tensor): cover norm()/normalize() along an axis Adds regression tests for the two bugs fixed in the preceding commits: - norm() with an axis now actually accumulates values (buffer iteration) and takes the 1/ord root for L2. - normalize() actually divides every element by the computed norm, producing a unit-length vector. All four new tests fail against the pre-fix implementation. --- tests/tensors/TensorTest.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/tensors/TensorTest.php b/tests/tensors/TensorTest.php index 3c76538..67e29aa 100644 --- a/tests/tensors/TensorTest.php +++ b/tests/tensors/TensorTest.php @@ -290,6 +290,33 @@ expect($values->toArray())->toBe([5.0, 4.0, 3.0]) ->and($indices->toArray())->toBe([4, 1, 2]); }); + + it('can calculate L2 norm along an axis', function () { + $t = new Tensor([[3.0, 4.0], [6.0, 8.0]]); + $norm = $t->norm(2, -1); + expect($norm->toArray())->toBe([5.0, 10.0]); + }); + + it('can calculate L2 norm along an axis while keeping the reduced dimension', function () { + $t = new Tensor([[3.0, 4.0], [6.0, 8.0]]); + $norm = $t->norm(2, -1, keepShape: true); + expect($norm->shape())->toBe([2, 1]) + ->and($norm->toArray())->toBe([[5.0], [10.0]]); + }); + + it('can normalize a tensor along an axis', function () { + $t = new Tensor([[3.0, 4.0], [6.0, 8.0]]); + $normalized = $t->normalize(2, -1)->toArray(); + expect($normalized[0])->toMatchArrayApproximately([0.6, 0.8], 1e-6) + ->and($normalized[1])->toMatchArrayApproximately([0.6, 0.8], 1e-6); + }); + + it('normalized rows have unit L2 norm', function () { + $t = new Tensor([[1.0, 2.0, 3.0, 4.0]]); + $normalized = $t->normalize(2, -1); + $sumOfSquares = array_sum(array_map(fn ($v) => $v ** 2, $normalized->toArray()[0])); + expect($sumOfSquares)->toEqualWithDelta(1.0, 1e-6); + }); }); describe('Error handling', function () {