From 258a678ef4612b4c2cd715b2a6587c40924637bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgaras0x4E=C2=A0?= Date: Tue, 13 Oct 2026 21:55:42 +0300 Subject: [PATCH] v1.2.0 - PHP 8.2 support, performance and fixes --- .gitattributes | 5 ++ .github/workflows/tests.yml | 51 ++++++++++++++++++ .gitignore | 3 +- LICENSE | 21 ++++++++ README.md | 72 +++++++++++++++---------- composer.json | 12 ++++- phpunit.xml.dist | 22 ++++++++ src/Cosine.php | 26 ++++----- src/DamerauLevenshtein.php | 42 +++++++-------- src/Hamming.php | 19 ++++--- src/Jaccard.php | 21 ++------ src/Jaro.php | 18 ++++--- src/JaroWinkler.php | 9 ++-- src/LCS.php | 28 +++++----- src/Levenshtein.php | 36 ++++++------- src/NeedlemanWunsch.php | 30 ++++++----- src/SmithWaterman.php | 28 +++++----- tests/CosineTest.php | 38 +++++++++++++ tests/DamerauLevenshteinTest.php | 7 +++ tests/InvalidUtf8Test.php | 74 +++++++++++++++++++++++++ tests/JaroTest.php | 9 ++++ tests/JaroWinklerTest.php | 12 +++++ tests/LargeInputTest.php | 89 ++++++++++++++++++++++++++++++ tests/LevenshteinTest.php | 18 +++++++ tests/MongeElkanTest.php | 18 +++++++ tests/NeedlemanWunschTest.php | 12 +++++ tests/ReadmeExamplesTest.php | 92 ++++++++++++++++++++++++++++++++ tests/SmithWatermanTest.php | 11 ++++ 28 files changed, 667 insertions(+), 156 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/tests.yml create mode 100755 LICENSE create mode 100644 phpunit.xml.dist create mode 100644 tests/InvalidUtf8Test.php create mode 100644 tests/LargeInputTest.php create mode 100644 tests/ReadmeExamplesTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f1fe42f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/.github export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a63ee05 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,51 @@ +name: Tests + +on: + push: + branches: [master, dev] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: PHP ${{ matrix.php }} (${{ matrix.dependencies }}) + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4', '8.5'] + dependencies: [highest] + experimental: [false] + include: + - php: '8.2' + dependencies: lowest + experimental: false + - php: '8.6' + dependencies: highest + experimental: true + steps: + - uses: actions/checkout@v7 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring + coverage: none + + - name: Validate composer.json + run: composer validate --strict + + - uses: ramsey/composer-install@v4 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: Run tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index ce7b0bd..6b9f991 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ vendor/ .vscode/ -composer.lock \ No newline at end of file +composer.lock +.phpunit.cache/ diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..45b1a99 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Edgaras + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c0b2c8b..6084f0c 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,30 @@ -# StrSim v1.1.1 +# StrSim v1.2.0 A collection of string similarity and distance algorithms implemented in PHP with full Unicode and multibyte character support. This library provides standalone static methods for computing various similarity metrics, useful in natural language processing, fuzzy matching, spell checking, and bioinformatics. -## What's New in v1.1.1 +## What's New in v1.2.0 -### 🔧 **Fixed Naming Issues** -- **Fixed `Jaro::distance()`** - Previously returned similarity values (1.0 = identical), now correctly returns distance values (0.0 = identical) -- **Fixed `JaroWinkler::distance()`** - Previously returned similarity values (1.0 = identical), now correctly returns distance values (0.0 = identical) +No changes are needed in calling code. Method names, signatures, return types and exception messages are unchanged. -### ✨ **New Functions Added** -- **`Jaro::similarity()`** - Returns proper similarity values (1.0 = identical, 0.0 = completely different) -- **`JaroWinkler::similarity()`** - Returns proper similarity values (1.0 = identical, 0.0 = completely different) +### Compatibility +- PHP 8.2 is supported. The requirement was lowered from 8.3 because the code uses no PHP 8.3 feature. Tested on PHP 8.2, 8.3, 8.4 and 8.5. +- `ext-mbstring` is now listed in `composer.json`. Every method already needed it. -### 📚 **Improvements** -- **Better MongeElkan** - Fixed edge cases for empty string comparisons +### Fixes +- `Hamming::distance()` now rejects invalid UTF-8 with `InvalidArgumentException`, like the other classes. Before, it returned a number that differed between PHP 8.2 and 8.3+. +- `Cosine::similarity()` returns exactly `1.0` for identical strings and never returns more than `1.0`. Before, rounding could give `1.0000000000000002`. Other results change by at most about 3e-16. +- `Cosine::similarityFromVectors()` keeps results between `-1.0` and `1.0`. Arrays with different keys (for example after `array_filter()`) are compared by position instead of raising "Undefined array key" warnings. Arrays with the same keys are still matched by key. A `NAN` value no longer causes a warning on PHP 8.5. -### 🔄 **Migration Guide** -If you were using `Jaro::distance()` or `JaroWinkler::distance()` expecting similarity values (where 1.0 = identical): -- **Before**: `Jaro::distance("hello", "hello")` returned `1.0` -- **After**: Use `Jaro::similarity("hello", "hello")` to get `1.0`, or `Jaro::distance("hello", "hello")` returns `0.0` +### Performance +- Each string is split into characters once per call instead of calling `mb_substr()` for every character. The dynamic-programming algorithms keep two or three table rows instead of the full table. This change does not alter any result. +- One measurement on PHP 8.3 with two 2000-character strings: `Levenshtein::distance()` took 8949.9 ms and 70.5 MB in v1.1.1, and 163.4 ms and 0.3 MB in v1.2.0. Two 3000-character strings used to stop with a "memory exhausted" fatal error under the default 128 MB limit. They now return a result. --- ## Requirements -- PHP 8.3+ +- PHP 8.2+ +- mbstring extension - Composer ## Installation @@ -43,10 +43,10 @@ require __DIR__ . '/vendor/autoload.php'; ## Features -- **Full Unicode Support**: All algorithms handle multibyte characters, emoji, combining marks, and complex grapheme clusters -- **UTF-8 Validation**: Automatic validation of input strings with clear error messages +- **Full Unicode Support**: All algorithms accept any valid UTF-8 text, including multibyte characters, emoji and combining marks +- **UTF-8 Validation**: Every string method throws `InvalidArgumentException` for invalid UTF-8 - **Error Handling**: Proper exception types with descriptive messages -- **Code-Point Based**: Consistent behavior across all Unicode normalization forms +- **Code-Point Based**: Strings are compared by Unicode code point without normalization - **Optimized Tokenization**: Smart whitespace handling for text-based algorithms - **Distance vs Similarity**: Clear distinction between distance measures (0 = identical) and similarity measures (1 = identical) @@ -55,11 +55,11 @@ require __DIR__ . '/vendor/autoload.php'; | Class | Method | Return Range | Description | |--------------------|----------------------------|--------------|----------------------------------------------------------------------| | `Levenshtein` | `distance()` | 0 to ∞ | Number of insertions, deletions, or substitutions needed. | -| `DamerauLevenshtein` | `distance()` | 0 to ∞ | Levenshtein with transpositions included. | +| `DamerauLevenshtein` | `distance()` | 0 to ∞ | Optimal string alignment: Levenshtein plus adjacent transpositions. | | `Hamming` | `distance()` | 0 to ∞ | Number of differing positions (requires equal-length strings). | | `Jaro` | `similarity()` | 0.0 to 1.0 | Similarity based on character matches and transpositions. | | `Jaro` | `distance()` | 0.0 to 1.0 | Distance measure (1 - similarity). | -| `JaroWinkler` | `similarity()` | 0.0 to 1.0 | Jaro with a prefix match boost for similar string starts. | +| `JaroWinkler` | `similarity()` | 0.0 to 1.0 | Jaro with a prefix match boost (`prefixScale` 0.0 to 0.25). | | `JaroWinkler` | `distance()` | 0.0 to 1.0 | Distance measure (1 - similarity). | | `LCS` | `length()` | 0 to ∞ | Length of the longest common subsequence. | | `SmithWaterman` | `score()` | 0 to ∞ | Local alignment scoring for best-matching subsequences. | @@ -67,7 +67,7 @@ require __DIR__ . '/vendor/autoload.php'; | `Cosine` | `similarity()` | 0.0 to 1.0 | Similarity via character frequency vectors. | | `Cosine` | `similarityFromVectors()` | -1.0 to 1.0 | Cosine similarity for numeric vector inputs. | | `Jaccard` | `index()` | 0.0 to 1.0 | Ratio of shared to total unique characters. | -| `MongeElkan` | `similarity()` | 0.0 to 1.0 | Average best-word similarity using Jaro-Winkler internally. | +| `MongeElkan` | `similarity()` | 0.0 to 1.0 | Average best-word Jaro-Winkler similarity of the first string's words. | ## Understanding Distance vs Similarity @@ -119,25 +119,25 @@ JaroWinkler::similarity("martha", "marhta"); // Returns: 0.961 (similarity) JaroWinkler::distance("martha", "marhta"); // Returns: 0.039 (distance = 1 - similarity) // Finding common subsequence in DNA fragments -LCS::length("ACCGGTCGAGTGCGCGGAAGCCGGCCGAA", "GTCGTTCGGAATGCCGTTGCTCTGTAAA"); // Returns: 13 +LCS::length("ACCGGTCGAGTGCGCGGAAGCCGGCCGAA", "GTCGTTCGGAATGCCGTTGCTCTGTAAA"); // Returns: 20 // Local alignment score for substring match -SmithWaterman::score("ACACACTA", "AGCACACA"); // Returns: 11 +SmithWaterman::score("ACACACTA", "AGCACACA"); // Returns: 12 // Global alignment score for complete sequence match NeedlemanWunsch::score("GATTACA", "GCATGCU"); // Returns: 0 -// Comparing word frequency in short texts +// Comparing character frequency in short texts Cosine::similarity("night", "nacht"); // Returns: 0.6 // Comparing embedding vectors from NLP model -Cosine::similarityFromVectors([0.1, 0.2, 0.3], [0.1, 0.3, 0.4]); // Returns: 0.925 +Cosine::similarityFromVectors([0.1, 0.2, 0.3], [0.1, 0.3, 0.4]); // Returns: 0.996 -// Comparing token overlap in short strings +// Comparing character-set overlap in short strings Jaccard::index("abc", "bcd"); // Returns: 0.5 // Fuzzy match between two multi-word names -MongeElkan::similarity("john smith", "jon smythe"); // Returns: 0.822 +MongeElkan::similarity("john smith", "jon smythe"); // Returns: 0.896 ``` ### Unicode and Multibyte Examples @@ -154,8 +154,8 @@ Hamming::distance("👍🏽", "👍🏾"); // Returns: 1 // Different scripts and languages Jaro::similarity("привет", "привет"); // Returns: 1.0 (identical) Jaro::distance("привет", "привет"); // Returns: 0.0 (no distance) -JaroWinkler::similarity("عربي", "عربى"); // Returns: 0.9 (high similarity) -JaroWinkler::distance("عربي", "عربى"); // Returns: 0.1 (low distance) +JaroWinkler::similarity("عربي", "عربى"); // Returns: 0.883 (high similarity) +JaroWinkler::distance("عربي", "عربى"); // Returns: 0.117 (low distance) // ZWJ sequences and combining marks Levenshtein::distance("👨‍👩‍👧‍👦", "👨👩👧👦"); // Returns: 3 @@ -201,6 +201,20 @@ try { } ``` +## Notes + +- Strings are compared by code point. No Unicode normalization is applied, so NFC and NFD forms of the same text differ. +- `DamerauLevenshtein` is the optimal string alignment variant (restricted edit distance). +- `MongeElkan::similarity($a, $b)` is not symmetric. It averages over the words of `$a`. +- `JaroWinkler` `prefixScale` must be between 0 and 0.25. Larger values return results outside 0 to 1. + +## Testing + +```bash +composer install +composer test +``` + ## Useful links - [Levenshtein](https://en.wikipedia.org/wiki/Levenshtein_distance) diff --git a/composer.json b/composer.json index 1a386b3..8586e26 100755 --- a/composer.json +++ b/composer.json @@ -17,9 +17,17 @@ }, "minimum-stability": "stable", "require": { - "php": ">=8.3.0" + "php": ">=8.2", + "ext-mbstring": "*" }, "require-dev": { - "phpunit/phpunit": "^11.5" + "phpunit/phpunit": "^11.5.50" + }, + "scripts": { + "test": "phpunit" + }, + "support": { + "issues": "https://github.com/Edgaras0x4E/StrSim/issues", + "source": "https://github.com/Edgaras0x4E/StrSim" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..8f6dd87 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + tests + + + + + src + + + diff --git a/src/Cosine.php b/src/Cosine.php index edf1ab2..3a23269 100644 --- a/src/Cosine.php +++ b/src/Cosine.php @@ -6,7 +6,7 @@ public static function similarity(string $a, string $b): float { if (!mb_check_encoding($a, 'UTF-8') || !mb_check_encoding($b, 'UTF-8')) { throw new \InvalidArgumentException("Input strings must be valid UTF-8."); } - + $tokensA = self::countMbChars($a); $tokensB = self::countMbChars($b); $dot = 0; @@ -22,19 +22,11 @@ public static function similarity(string $a, string $b): float { $normB += $v * $v; } - return ($normA && $normB) ? $dot / (sqrt($normA) * sqrt($normB)) : 0; + return ($normA && $normB) ? min(1.0, $dot / sqrt($normA * $normB)) : 0.0; } private static function countMbChars(string $str): array { - $chars = []; - $length = mb_strlen($str, 'UTF-8'); - - for ($i = 0; $i < $length; $i++) { - $char = mb_substr($str, $i, 1, 'UTF-8'); - $chars[$char] = ($chars[$char] ?? 0) + 1; - } - - return $chars; + return array_count_values(mb_str_split($str, 1, 'UTF-8')); } public static function similarityFromVectors(array $vecA, array $vecB): float { @@ -42,6 +34,11 @@ public static function similarityFromVectors(array $vecA, array $vecB): float { throw new \InvalidArgumentException("Vectors must be the same length."); } + if (array_diff_key($vecA, $vecB)) { + $vecA = array_values($vecA); + $vecB = array_values($vecB); + } + $dot = 0.0; $normA = 0.0; $normB = 0.0; @@ -53,6 +50,11 @@ public static function similarityFromVectors(array $vecA, array $vecB): float { $normB += $valB * $valB; } - return ($normA && $normB) ? $dot / (sqrt($normA) * sqrt($normB)) : 0.0; + if ($normA != 0.0 && $normB != 0.0) { + $similarity = $dot / (sqrt($normA) * sqrt($normB)); + return is_nan($similarity) ? $similarity : max(-1.0, min(1.0, $similarity)); + } + + return 0.0; } } diff --git a/src/DamerauLevenshtein.php b/src/DamerauLevenshtein.php index 18accfe..a481679 100644 --- a/src/DamerauLevenshtein.php +++ b/src/DamerauLevenshtein.php @@ -6,36 +6,36 @@ public static function distance(string $a, string $b): int { if (!mb_check_encoding($a, 'UTF-8') || !mb_check_encoding($b, 'UTF-8')) { throw new \InvalidArgumentException("Input strings must be valid UTF-8."); } - - $lenA = mb_strlen($a, 'UTF-8'); - $lenB = mb_strlen($b, 'UTF-8'); - $dp = []; - for ($i = 0; $i <= $lenA; $i++) $dp[$i][0] = $i; - for ($j = 0; $j <= $lenB; $j++) $dp[0][$j] = $j; + $charsA = mb_str_split($a, 1, 'UTF-8'); + $charsB = mb_str_split($b, 1, 'UTF-8'); + $lenA = count($charsA); + $lenB = count($charsB); + + $prev2 = []; + $prev = range(0, $lenB); for ($i = 1; $i <= $lenA; $i++) { + $cur = [$i]; + $charA = $charsA[$i - 1]; for ($j = 1; $j <= $lenB; $j++) { - $charA = mb_substr($a, $i - 1, 1, 'UTF-8'); - $charB = mb_substr($b, $j - 1, 1, 'UTF-8'); + $charB = $charsB[$j - 1]; $cost = ($charA === $charB) ? 0 : 1; - - $dp[$i][$j] = min( - $dp[$i - 1][$j] + 1, - $dp[$i][$j - 1] + 1, - $dp[$i - 1][$j - 1] + $cost + + $cur[$j] = min( + $prev[$j] + 1, + $cur[$j - 1] + 1, + $prev[$j - 1] + $cost ); - - if ($i > 1 && $j > 1) { - $charA2 = mb_substr($a, $i - 2, 1, 'UTF-8'); - $charB2 = mb_substr($b, $j - 2, 1, 'UTF-8'); - if ($charA === $charB2 && $charA2 === $charB) { - $dp[$i][$j] = min($dp[$i][$j], $dp[$i - 2][$j - 2] + $cost); - } + + if ($i > 1 && $j > 1 && $charA === $charsB[$j - 2] && $charsA[$i - 2] === $charB) { + $cur[$j] = min($cur[$j], $prev2[$j - 2] + $cost); } } + $prev2 = $prev; + $prev = $cur; } - return $dp[$lenA][$lenB]; + return $prev[$lenB]; } } diff --git a/src/Hamming.php b/src/Hamming.php index 5282090..05013d8 100644 --- a/src/Hamming.php +++ b/src/Hamming.php @@ -3,19 +3,24 @@ class Hamming { public static function distance(string $a, string $b): int { - $lenA = mb_strlen($a, 'UTF-8'); - $lenB = mb_strlen($b, 'UTF-8'); - - if ($lenA !== $lenB) { + if (!mb_check_encoding($a, 'UTF-8') || !mb_check_encoding($b, 'UTF-8')) { + throw new \InvalidArgumentException("Input strings must be valid UTF-8."); + } + + $charsA = mb_str_split($a, 1, 'UTF-8'); + $charsB = mb_str_split($b, 1, 'UTF-8'); + $length = count($charsA); + + if ($length !== count($charsB)) { throw new \InvalidArgumentException("Strings must be of equal length."); } $distance = 0; - for ($i = 0; $i < $lenA; $i++) { - if (mb_substr($a, $i, 1, 'UTF-8') !== mb_substr($b, $i, 1, 'UTF-8')) { + for ($i = 0; $i < $length; $i++) { + if ($charsA[$i] !== $charsB[$i]) { $distance++; } - } + } return $distance; } diff --git a/src/Jaccard.php b/src/Jaccard.php index 0287645..cc8c390 100644 --- a/src/Jaccard.php +++ b/src/Jaccard.php @@ -6,11 +6,11 @@ public static function index(string $a, string $b): float { if (!mb_check_encoding($a, 'UTF-8') || !mb_check_encoding($b, 'UTF-8')) { throw new \InvalidArgumentException("Input strings must be valid UTF-8."); } - - $setA = array_unique(self::mbStrSplit($a)); - $setB = array_unique(self::mbStrSplit($b)); - $intersection = array_intersect($setA, $setB); - $union = array_unique(array_merge($setA, $setB)); + + $setA = array_flip(mb_str_split($a, 1, 'UTF-8')); + $setB = array_flip(mb_str_split($b, 1, 'UTF-8')); + $intersection = array_intersect_key($setA, $setB); + $union = $setA + $setB; if (count($union) === 0) { return 0.0; @@ -18,15 +18,4 @@ public static function index(string $a, string $b): float { return count($intersection) / count($union); } - - private static function mbStrSplit(string $str): array { - $chars = []; - $length = mb_strlen($str, 'UTF-8'); - - for ($i = 0; $i < $length; $i++) { - $chars[] = mb_substr($str, $i, 1, 'UTF-8'); - } - - return $chars; - } } diff --git a/src/Jaro.php b/src/Jaro.php index edcf1d3..2346c8c 100644 --- a/src/Jaro.php +++ b/src/Jaro.php @@ -2,14 +2,16 @@ namespace Edgaras\StrSim; class Jaro { - + public static function similarity(string $s1, string $s2): float { if (!mb_check_encoding($s1, 'UTF-8') || !mb_check_encoding($s2, 'UTF-8')) { throw new \InvalidArgumentException("Input strings must be valid UTF-8."); } - - $len1 = mb_strlen($s1, 'UTF-8'); - $len2 = mb_strlen($s2, 'UTF-8'); + + $chars1 = mb_str_split($s1, 1, 'UTF-8'); + $chars2 = mb_str_split($s2, 1, 'UTF-8'); + $len1 = count($chars1); + $len2 = count($chars2); if ($len1 === 0 && $len2 === 0) return 1.0; $matchDistance = max((int)(max($len1, $len2) / 2) - 1, 0); @@ -24,7 +26,7 @@ public static function similarity(string $s1, string $s2): float { for ($j = $start; $j < $end; $j++) { if ($s2Matches[$j]) continue; - if (mb_substr($s1, $i, 1, 'UTF-8') !== mb_substr($s2, $j, 1, 'UTF-8')) continue; + if ($chars1[$i] !== $chars2[$j]) continue; $s1Matches[$i] = $s2Matches[$j] = true; $matches++; break; @@ -37,14 +39,14 @@ public static function similarity(string $s1, string $s2): float { for ($i = 0; $i < $len1; $i++) { if (!$s1Matches[$i]) continue; while (!$s2Matches[$k]) $k++; - if (mb_substr($s1, $i, 1, 'UTF-8') !== mb_substr($s2, $k, 1, 'UTF-8')) $transpositions++; + if ($chars1[$i] !== $chars2[$k]) $transpositions++; $k++; } return (($matches / $len1) + ($matches / $len2) + (($matches - $transpositions / 2) / $matches)) / 3.0; } - + public static function distance(string $s1, string $s2): float { return 1.0 - self::similarity($s1, $s2); } -} \ No newline at end of file +} diff --git a/src/JaroWinkler.php b/src/JaroWinkler.php index 6cfbe6c..b354125 100644 --- a/src/JaroWinkler.php +++ b/src/JaroWinkler.php @@ -2,24 +2,25 @@ namespace Edgaras\StrSim; class JaroWinkler { - + public static function similarity(string $s1, string $s2, float $prefixScale = 0.1): float { if (!mb_check_encoding($s1, 'UTF-8') || !mb_check_encoding($s2, 'UTF-8')) { throw new \InvalidArgumentException("Input strings must be valid UTF-8."); } - + $jaro = Jaro::similarity($s1, $s2); $prefix = 0; $maxPrefix = 4; + $limit = min($maxPrefix, mb_strlen($s1, 'UTF-8'), mb_strlen($s2, 'UTF-8')); - for ($i = 0; $i < min($maxPrefix, mb_strlen($s1, 'UTF-8'), mb_strlen($s2, 'UTF-8')); $i++) { + for ($i = 0; $i < $limit; $i++) { if (mb_substr($s1, $i, 1, 'UTF-8') === mb_substr($s2, $i, 1, 'UTF-8')) $prefix++; else break; } return $jaro + $prefix * $prefixScale * (1 - $jaro); } - + public static function distance(string $s1, string $s2, float $prefixScale = 0.1): float { return 1.0 - self::similarity($s1, $s2, $prefixScale); } diff --git a/src/LCS.php b/src/LCS.php index e151768..da07215 100644 --- a/src/LCS.php +++ b/src/LCS.php @@ -6,23 +6,27 @@ public static function length(string $a, string $b): int { if (!mb_check_encoding($a, 'UTF-8') || !mb_check_encoding($b, 'UTF-8')) { throw new \InvalidArgumentException("Input strings must be valid UTF-8."); } - - $m = mb_strlen($a, 'UTF-8'); - $n = mb_strlen($b, 'UTF-8'); - $dp = []; - for ($i = 0; $i <= $m; $i++) { - for ($j = 0; $j <= $n; $j++) { - if ($i === 0 || $j === 0) { - $dp[$i][$j] = 0; - } elseif (mb_substr($a, $i - 1, 1, 'UTF-8') === mb_substr($b, $j - 1, 1, 'UTF-8')) { - $dp[$i][$j] = $dp[$i - 1][$j - 1] + 1; + $charsA = mb_str_split($a, 1, 'UTF-8'); + $charsB = mb_str_split($b, 1, 'UTF-8'); + $m = count($charsA); + $n = count($charsB); + + $prev = array_fill(0, $n + 1, 0); + + for ($i = 1; $i <= $m; $i++) { + $cur = [0]; + $charA = $charsA[$i - 1]; + for ($j = 1; $j <= $n; $j++) { + if ($charA === $charsB[$j - 1]) { + $cur[$j] = $prev[$j - 1] + 1; } else { - $dp[$i][$j] = max($dp[$i - 1][$j], $dp[$i][$j - 1]); + $cur[$j] = max($prev[$j], $cur[$j - 1]); } } + $prev = $cur; } - return $dp[$m][$n]; + return $prev[$n]; } } diff --git a/src/Levenshtein.php b/src/Levenshtein.php index c5e86d9..6eac7fb 100644 --- a/src/Levenshtein.php +++ b/src/Levenshtein.php @@ -6,32 +6,32 @@ public static function distance(string $a, string $b): int { if (!mb_check_encoding($a, 'UTF-8') || !mb_check_encoding($b, 'UTF-8')) { throw new \InvalidArgumentException("Input strings must be valid UTF-8."); } - - $lenA = mb_strlen($a, 'UTF-8'); - $lenB = mb_strlen($b, 'UTF-8'); - + + $charsA = mb_str_split($a, 1, 'UTF-8'); + $charsB = mb_str_split($b, 1, 'UTF-8'); + $lenA = count($charsA); + $lenB = count($charsB); + if ($lenA === 0) return $lenB; if ($lenB === 0) return $lenA; - $dp = []; - - for ($i = 0; $i <= $lenA; $i++) $dp[$i][0] = $i; - for ($j = 0; $j <= $lenB; $j++) $dp[0][$j] = $j; + $prev = range(0, $lenB); for ($i = 1; $i <= $lenA; $i++) { + $cur = [$i]; + $charA = $charsA[$i - 1]; for ($j = 1; $j <= $lenB; $j++) { - $charA = mb_substr($a, $i - 1, 1, 'UTF-8'); - $charB = mb_substr($b, $j - 1, 1, 'UTF-8'); - $cost = ($charA === $charB) ? 0 : 1; - - $dp[$i][$j] = min( - $dp[$i - 1][$j] + 1, - $dp[$i][$j - 1] + 1, - $dp[$i - 1][$j - 1] + $cost + $cost = ($charA === $charsB[$j - 1]) ? 0 : 1; + + $cur[$j] = min( + $prev[$j] + 1, + $cur[$j - 1] + 1, + $prev[$j - 1] + $cost ); } + $prev = $cur; } - return $dp[$lenA][$lenB]; + return $prev[$lenB]; } -} \ No newline at end of file +} diff --git a/src/NeedlemanWunsch.php b/src/NeedlemanWunsch.php index 365a949..fed13d9 100644 --- a/src/NeedlemanWunsch.php +++ b/src/NeedlemanWunsch.php @@ -2,33 +2,35 @@ namespace Edgaras\StrSim; class NeedlemanWunsch { - + public static function score(string $a, string $b, int $match = 1, int $mismatch = -1, int $gap = -1): int { if (!mb_check_encoding($a, 'UTF-8') || !mb_check_encoding($b, 'UTF-8')) { throw new \InvalidArgumentException("Input strings must be valid UTF-8."); } - $m = mb_strlen($a, 'UTF-8'); - $n = mb_strlen($b, 'UTF-8'); - $dp = []; + $charsA = mb_str_split($a, 1, 'UTF-8'); + $charsB = mb_str_split($b, 1, 'UTF-8'); + $m = count($charsA); + $n = count($charsB); - for ($i = 0; $i <= $m; $i++) $dp[$i][0] = $i * $gap; - for ($j = 0; $j <= $n; $j++) $dp[0][$j] = $j * $gap; + $prev = []; + for ($j = 0; $j <= $n; $j++) $prev[$j] = $j * $gap; for ($i = 1; $i <= $m; $i++) { - $charA = mb_substr($a, $i - 1, 1, 'UTF-8'); + $cur = [$i * $gap]; + $charA = $charsA[$i - 1]; for ($j = 1; $j <= $n; $j++) { - $charB = mb_substr($b, $j - 1, 1, 'UTF-8'); - $score = ($charA === $charB) ? $match : $mismatch; - $dp[$i][$j] = max( - $dp[$i - 1][$j - 1] + $score, - $dp[$i - 1][$j] + $gap, - $dp[$i][$j - 1] + $gap + $score = ($charA === $charsB[$j - 1]) ? $match : $mismatch; + $cur[$j] = max( + $prev[$j - 1] + $score, + $prev[$j] + $gap, + $cur[$j - 1] + $gap ); } + $prev = $cur; } - return $dp[$m][$n]; + return $prev[$n]; } } diff --git a/src/SmithWaterman.php b/src/SmithWaterman.php index 203d294..d956695 100644 --- a/src/SmithWaterman.php +++ b/src/SmithWaterman.php @@ -7,26 +7,30 @@ public static function score(string $a, string $b, int $match = 2, int $mismatch throw new \InvalidArgumentException("Input strings must be valid UTF-8."); } - $m = mb_strlen($a, 'UTF-8'); - $n = mb_strlen($b, 'UTF-8'); - $dp = array_fill(0, $m + 1, array_fill(0, $n + 1, 0)); + $charsA = mb_str_split($a, 1, 'UTF-8'); + $charsB = mb_str_split($b, 1, 'UTF-8'); + $m = count($charsA); + $n = count($charsB); + + $prev = array_fill(0, $n + 1, 0); $max = 0; for ($i = 1; $i <= $m; $i++) { - $charA = mb_substr($a, $i - 1, 1, 'UTF-8'); + $cur = [0]; + $charA = $charsA[$i - 1]; for ($j = 1; $j <= $n; $j++) { - $charB = mb_substr($b, $j - 1, 1, 'UTF-8'); - $score = ($charA === $charB) ? $match : $mismatch; - $dp[$i][$j] = max( + $score = ($charA === $charsB[$j - 1]) ? $match : $mismatch; + $cur[$j] = max( 0, - $dp[$i - 1][$j - 1] + $score, - $dp[$i - 1][$j] + $gap, - $dp[$i][$j - 1] + $gap + $prev[$j - 1] + $score, + $prev[$j] + $gap, + $cur[$j - 1] + $gap ); - $max = max($max, $dp[$i][$j]); + $max = max($max, $cur[$j]); } + $prev = $cur; } return $max; } -} \ No newline at end of file +} diff --git a/tests/CosineTest.php b/tests/CosineTest.php index a4fed2e..93d5dab 100644 --- a/tests/CosineTest.php +++ b/tests/CosineTest.php @@ -163,4 +163,42 @@ public function testInvalidUtf8Input() $invalid = "\xFF\xFF"; Cosine::similarity($invalid, "test"); } + + public function testIdenticalStringsReturnExactlyOne() + { + $letters = range('a', 'z'); + for ($n = 1; $n <= 26; $n++) { + $s = implode('', array_slice($letters, 0, $n)); + $this->assertSame(1.0, Cosine::similarity($s, $s), "length $n"); + } + $this->assertSame(1.0, Cosine::similarity("listen", "silent")); + $this->assertSame(1.0, Cosine::similarity("hello", "hello")); + } + + public function testExactValueForReadmeExample() + { + $this->assertSame(0.6, Cosine::similarity("night", "nacht")); + } + + public function testVectorResultsStayInRange() + { + $this->assertSame(1.0, Cosine::similarityFromVectors([1, 1, 1], [1, 1, 1])); + $this->assertSame(-1.0, Cosine::similarityFromVectors([1, 1, 1], [-1, -1, -1])); + } + + public function testVectorsWithSameKeysInDifferentOrderAreMatchedByKey() + { + $this->assertEqualsWithDelta(1.0, Cosine::similarityFromVectors(['x' => 1, 'y' => 0], ['y' => 0, 'x' => 1]), 1e-12); + } + + public function testVectorsWithDifferentKeysAreComparedByPosition() + { + $this->assertEqualsWithDelta(1.0, Cosine::similarityFromVectors([0 => 1, 2 => 3], [1, 3]), 1e-12); + $this->assertEqualsWithDelta(0.0, Cosine::similarityFromVectors(['a' => 1, 'b' => 0], ['c' => 0, 'd' => 1]), 1e-12); + } + + public function testVectorWithNanReturnsNanWithoutWarning() + { + $this->assertNan(Cosine::similarityFromVectors([NAN, 1.0], [1.0, 1.0])); + } } diff --git a/tests/DamerauLevenshteinTest.php b/tests/DamerauLevenshteinTest.php index 5fa15c1..189e8b8 100644 --- a/tests/DamerauLevenshteinTest.php +++ b/tests/DamerauLevenshteinTest.php @@ -37,6 +37,13 @@ public function testComplexCase() $this->assertSame(3, DamerauLevenshtein::distance("ca", "abc")); } + public function testOptimalStringAlignmentVariant() + { + $this->assertSame(3, DamerauLevenshtein::distance("CA", "ABC")); + $this->assertSame(1, DamerauLevenshtein::distance("CA", "AC")); + $this->assertSame(1, DamerauLevenshtein::distance("AC", "ABC")); + } + public function testEmptyToNonEmpty() { $this->assertSame(4, DamerauLevenshtein::distance("", "test")); diff --git a/tests/InvalidUtf8Test.php b/tests/InvalidUtf8Test.php new file mode 100644 index 0000000..d8eb795 --- /dev/null +++ b/tests/InvalidUtf8Test.php @@ -0,0 +1,74 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Input strings must be valid UTF-8."); + $class::$method($a, $b); + } + + public static function invalidSequences(): array + { + return [ + 'invalid byte' => ["\xFF"], + 'lone continuation byte' => ["\x80"], + 'truncated sequence' => ["\xE2\x82"], + 'overlong encoding' => ["\xC0\xAF"], + 'UTF-16 surrogate' => ["\xED\xA0\x80"], + ]; + } + + #[DataProvider('invalidSequences')] + public function testHammingRejectsInvalidSequenceBeforeLengthCheck(string $invalid) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Input strings must be valid UTF-8."); + Hamming::distance($invalid, "x"); + } +} diff --git a/tests/JaroTest.php b/tests/JaroTest.php index fc63685..211d36f 100644 --- a/tests/JaroTest.php +++ b/tests/JaroTest.php @@ -251,6 +251,15 @@ public function testDistanceExactJaroValues() $this->assertLessThan(1.0, $result); } + public function testSimilarityReferenceValues() + { + $this->assertEqualsWithDelta(0.9444444444, Jaro::similarity("MARTHA", "MARHTA"), 1e-9); + $this->assertEqualsWithDelta(0.8222222222, Jaro::similarity("DWAYNE", "DUANE"), 1e-9); + $this->assertEqualsWithDelta(0.7666666667, Jaro::similarity("DIXON", "DICKSONX"), 1e-9); + $this->assertEqualsWithDelta(0.8962962963, Jaro::similarity("JELLYFISH", "SMELLYFISH"), 1e-9); + $this->assertEqualsWithDelta(0.7333333333, Jaro::similarity("CRATE", "TRACE"), 1e-9); + } + public function testInvalidUtf8InputDistance() { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/JaroWinklerTest.php b/tests/JaroWinklerTest.php index bdb40fa..c9495a9 100644 --- a/tests/JaroWinklerTest.php +++ b/tests/JaroWinklerTest.php @@ -321,6 +321,18 @@ public function testDistanceWindowBoundaryCases() $this->assertEqualsWithDelta(1.0, $result, 1e-10); } + public function testSimilarityReferenceValues() + { + $this->assertEqualsWithDelta(0.9611111111, JaroWinkler::similarity("MARTHA", "MARHTA"), 1e-9); + $this->assertEqualsWithDelta(0.84, JaroWinkler::similarity("DWAYNE", "DUANE"), 1e-9); + $this->assertEqualsWithDelta(0.8133333333, JaroWinkler::similarity("DIXON", "DICKSONX"), 1e-9); + } + + public function testSimilarityMaximumPrefixScale() + { + $this->assertSame(1.0, JaroWinkler::similarity("abcdwxyz", "abcdqrst", 0.25)); + } + public function testInvalidUtf8InputDistance() { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/LargeInputTest.php b/tests/LargeInputTest.php new file mode 100644 index 0000000..9b65bd6 --- /dev/null +++ b/tests/LargeInputTest.php @@ -0,0 +1,89 @@ +measurePeak(fn() => Levenshtein::distance($a, $b)); + + $this->assertSame(levenshtein($a, $b), $result); + $this->assertLessThan(self::MAX_PEAK_BYTES, $peak); + } + + public function testDamerauLevenshteinLinearMemory() + { + $a = self::randomString(3, ['a', 'c', 'é', '🚀']); + $b = self::randomString(4, ['a', 'c', 'é', '🚀']); + + [$result, $peak] = $this->measurePeak(fn() => DamerauLevenshtein::distance($a, $b)); + + $this->assertLessThanOrEqual(Levenshtein::distance($a, $b), $result); + $this->assertLessThan(self::MAX_PEAK_BYTES, $peak); + } + + public function testLcsLinearMemory() + { + $a = self::randomString(5, ['a', 'c', 'é', '🚀']); + $b = self::randomString(6, ['a', 'c', 'é', '🚀']); + + [$result, $peak] = $this->measurePeak(fn() => LCS::length($a, $b)); + + $d = Levenshtein::distance($a, $b); + $this->assertGreaterThanOrEqual(self::LENGTH - $d, $result); + $this->assertLessThanOrEqual(self::LENGTH - $d / 2, $result); + $this->assertLessThan(self::MAX_PEAK_BYTES, $peak); + } + + public function testSmithWatermanLinearMemory() + { + $a = self::randomString(7, ['a', 'c', 'é', '🚀']); + + [$result, $peak] = $this->measurePeak(fn() => SmithWaterman::score($a, $a)); + + $this->assertSame(2 * self::LENGTH, $result); + $this->assertLessThan(self::MAX_PEAK_BYTES, $peak); + } + + public function testNeedlemanWunschLinearMemory() + { + $a = self::randomString(8, ['a', 'c', 'é', '🚀']); + + [$result, $peak] = $this->measurePeak(fn() => NeedlemanWunsch::score($a, $a)); + + $this->assertSame(self::LENGTH, $result); + $this->assertLessThan(self::MAX_PEAK_BYTES, $peak); + } +} diff --git a/tests/LevenshteinTest.php b/tests/LevenshteinTest.php index 8d67ef6..8394bd2 100644 --- a/tests/LevenshteinTest.php +++ b/tests/LevenshteinTest.php @@ -134,4 +134,22 @@ public function testSkinToneModifierDistance() { $this->assertSame(1, Levenshtein::distance("👍", "👍🏽")); } + + public function testSymmetry() + { + $pairs = [["kitten", "sitting"], ["", "abc"], ["café", "caffe"], ["🚀🌟", "⭐"], ["flaw", "lawn"]]; + foreach ($pairs as [$a, $b]) { + $this->assertSame(Levenshtein::distance($a, $b), Levenshtein::distance($b, $a)); + } + } + + public function testMatchesBuiltinLevenshteinOnAscii() + { + mt_srand(42); + for ($k = 0; $k < 200; $k++) { + $a = substr(str_shuffle(str_repeat('abcd', 4)), 0, mt_rand(0, 12)); + $b = substr(str_shuffle(str_repeat('abcd', 4)), 0, mt_rand(0, 12)); + $this->assertSame(levenshtein($a, $b), Levenshtein::distance($a, $b), "'$a' vs '$b'"); + } + } } diff --git a/tests/MongeElkanTest.php b/tests/MongeElkanTest.php index b1c15b6..3e56294 100644 --- a/tests/MongeElkanTest.php +++ b/tests/MongeElkanTest.php @@ -49,6 +49,24 @@ public function testOneEmpty() $this->assertEqualsWithDelta(0.0, MongeElkan::similarity("test", ""), 1e-10); } + public function testEmptyFirstArgument() + { + $this->assertEqualsWithDelta(0.0, MongeElkan::similarity("", "test"), 1e-10); + } + + public function testWhitespaceOnlyInput() + { + $this->assertEqualsWithDelta(1.0, MongeElkan::similarity(" ", "\t"), 1e-10); + $this->assertEqualsWithDelta(1.0, MongeElkan::similarity(" ", ""), 1e-10); + $this->assertEqualsWithDelta(0.0, MongeElkan::similarity(" ", "test"), 1e-10); + $this->assertEqualsWithDelta(0.0, MongeElkan::similarity("test", " "), 1e-10); + } + + public function testPartialMatchReferenceValue() + { + $this->assertEqualsWithDelta(0.8955555556, MongeElkan::similarity("john smith", "jon smythe"), 1e-9); + } + public function testMultibyteIdenticalSentences() { $this->assertEqualsWithDelta(1.0, MongeElkan::similarity("café latte", "café latte"), 1e-10); diff --git a/tests/NeedlemanWunschTest.php b/tests/NeedlemanWunschTest.php index 407a07e..7b84339 100644 --- a/tests/NeedlemanWunschTest.php +++ b/tests/NeedlemanWunschTest.php @@ -38,6 +38,18 @@ public function testCustomScoring() $this->assertSame(20, NeedlemanWunsch::score("AAAA", "AAAA", match: 5, mismatch: -1, gap: -2)); } + public function testCustomScoringWithMismatchAndGaps() + { + $this->assertSame(-4, NeedlemanWunsch::score("GATTACA", "GCAT", match: 2, mismatch: -1, gap: -2)); + $this->assertSame(-2, NeedlemanWunsch::score("ACGTACGT", "ACT", match: 1, mismatch: -3, gap: -1)); + $this->assertSame(0, NeedlemanWunsch::score("AB", "AC", match: 1, mismatch: -1, gap: -1)); + $this->assertSame(-1, NeedlemanWunsch::score("AB", "AC", match: 1, mismatch: -3, gap: -1)); + $this->assertSame(1, NeedlemanWunsch::score("AB", "AC", match: 1, mismatch: 0, gap: -1)); + $this->assertSame(0, NeedlemanWunsch::score("ACGT", "AT", match: 1, mismatch: -1, gap: -1)); + $this->assertSame(-2, NeedlemanWunsch::score("ACGT", "AT", match: 1, mismatch: -1, gap: -2)); + $this->assertSame(-2, NeedlemanWunsch::score("AT", "ACGT", match: 1, mismatch: -1, gap: -2)); + } + public function testGapOnly() { $this->assertSame(-1, NeedlemanWunsch::score("A", "AAA")); diff --git a/tests/ReadmeExamplesTest.php b/tests/ReadmeExamplesTest.php new file mode 100644 index 0000000..2cf81eb --- /dev/null +++ b/tests/ReadmeExamplesTest.php @@ -0,0 +1,92 @@ + [Levenshtein::class, 'distance', ["kitten", "sitting"], 3], + 'DamerauLevenshtein abcd/acbd' => [DamerauLevenshtein::class, 'distance', ["abcd", "acbd"], 1], + 'Hamming bits' => [Hamming::class, 'distance', ["1011101", "1001001"], 2], + 'Jaro similarity dixon/dicksonx' => [Jaro::class, 'similarity', ["dixon", "dicksonx"], 0.767], + 'Jaro distance dixon/dicksonx' => [Jaro::class, 'distance', ["dixon", "dicksonx"], 0.233], + 'JaroWinkler similarity martha/marhta' => [JaroWinkler::class, 'similarity', ["martha", "marhta"], 0.961], + 'JaroWinkler distance martha/marhta' => [JaroWinkler::class, 'distance', ["martha", "marhta"], 0.039], + 'LCS DNA' => [LCS::class, 'length', ["ACCGGTCGAGTGCGCGGAAGCCGGCCGAA", "GTCGTTCGGAATGCCGTTGCTCTGTAAA"], 20], + 'SmithWaterman local alignment' => [SmithWaterman::class, 'score', ["ACACACTA", "AGCACACA"], 12], + 'NeedlemanWunsch global alignment' => [NeedlemanWunsch::class, 'score', ["GATTACA", "GCATGCU"], 0], + 'Cosine night/nacht' => [Cosine::class, 'similarity', ["night", "nacht"], 0.6], + 'Cosine vectors' => [Cosine::class, 'similarityFromVectors', [[0.1, 0.2, 0.3], [0.1, 0.3, 0.4]], 0.996], + 'Jaccard abc/bcd' => [Jaccard::class, 'index', ["abc", "bcd"], 0.5], + 'MongeElkan names' => [MongeElkan::class, 'similarity', ["john smith", "jon smythe"], 0.896], + + 'Levenshtein accented' => [Levenshtein::class, 'distance', ["café", "caffe"], 2], + 'Levenshtein Japanese' => [Levenshtein::class, 'distance', ["こんにちは", "こんにちわ"], 1], + 'Levenshtein emoji' => [Levenshtein::class, 'distance', ["\u{1F680}\u{1F31F}", "\u{1F680}\u{2B50}"], 1], + 'Hamming skin tones' => [Hamming::class, 'distance', ["\u{1F44D}\u{1F3FD}", "\u{1F44D}\u{1F3FE}"], 1], + 'Jaro similarity Cyrillic' => [Jaro::class, 'similarity', ["привет", "привет"], 1.0], + 'Jaro distance Cyrillic' => [Jaro::class, 'distance', ["привет", "привет"], 0.0], + 'JaroWinkler similarity Arabic' => [JaroWinkler::class, 'similarity', ["عربي", "عربى"], 0.883], + 'JaroWinkler distance Arabic' => [JaroWinkler::class, 'distance', ["عربي", "عربى"], 0.117], + 'Levenshtein ZWJ family' => [ + Levenshtein::class, + 'distance', + ["\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}", "\u{1F468}\u{1F469}\u{1F467}\u{1F466}"], + 3, + ], + 'Levenshtein combining mark' => [Levenshtein::class, 'distance', ["\u{00E9}", "e\u{0301}"], 2], + + 'SmithWaterman custom' => [SmithWaterman::class, 'score', ["ACGT", "ACGT", 5, -2, -1], 20], + 'NeedlemanWunsch custom' => [NeedlemanWunsch::class, 'score', ["ACGT", "ACGT", 3, -1, -2], 12], + ]; + } + + #[DataProvider('examples')] + public function testReadmeExample(string $class, string $method, array $args, int|float $expected) + { + $actual = $class::$method(...$args); + + if (is_int($expected)) { + $this->assertSame($expected, $actual); + } else { + $this->assertEqualsWithDelta($expected, $actual, 0.0005); + } + } + + public function testErrorHandlingHammingUnequalLength() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Strings must be of equal length."); + Hamming::distance("abc", "abcd"); + } + + public function testErrorHandlingInvalidUtf8() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Input strings must be valid UTF-8."); + Levenshtein::distance("valid", "\xFF\xFF"); + } + + public function testErrorHandlingVectorLength() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Vectors must be the same length."); + Cosine::similarityFromVectors([1, 2], [1, 2, 3]); + } +} diff --git a/tests/SmithWatermanTest.php b/tests/SmithWatermanTest.php index f577bf7..ceca01d 100644 --- a/tests/SmithWatermanTest.php +++ b/tests/SmithWatermanTest.php @@ -38,6 +38,17 @@ public function testCustomScoring() $this->assertSame(20, SmithWaterman::score("AAAA", "AAAA", match: 5, mismatch: -1, gap: -2)); } + public function testCustomScoringWithMismatchAndGaps() + { + $this->assertSame(13, SmithWaterman::score("TGTTACGG", "GGTTGACTA", match: 3, mismatch: -3, gap: -2)); + $this->assertSame(4, SmithWaterman::score("GATTACA", "GCATGCU", match: 2, mismatch: -3, gap: -2)); + $this->assertSame(3, SmithWaterman::score("ABD", "ACD", match: 2, mismatch: -1, gap: -5)); + $this->assertSame(2, SmithWaterman::score("ABD", "ACD", match: 2, mismatch: -3, gap: -5)); + $this->assertSame(4, SmithWaterman::score("ACGT", "AT", match: 3, mismatch: -1, gap: -1)); + $this->assertSame(3, SmithWaterman::score("ACGT", "AT", match: 3, mismatch: -1, gap: -2)); + $this->assertSame(3, SmithWaterman::score("AT", "ACGT", match: 3, mismatch: -1, gap: -2)); + } + public function testSubstringAlignment() { $this->assertSame(4, SmithWaterman::score("ACGT", "CG"));