Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Tests verify both PHP heap (`memory_get_usage()`) and native memory (VmRSS) stability
- 500KB threshold for memory growth detection across all test scenarios

- **TEST-004: Rerankers — Edge case coverage for RRF and Weighted re-rankers** (#102)
- Added 7 `.phpt` test files for reranker edge cases:
- `test_reranker_rrf_empty.phpt` — empty query results, null values, non-ZVecDoc elements
- `test_reranker_weighted_zero_range.phpt` — zero-range normalization guard (all scores identical)
- `test_reranker_weighted_float_min_bug.phpt` — PHP_FLOAT_MIN initialization bug detection
- `test_reranker_weighted_l2_metric.phpt` — L2 metric normalization (lower distance = higher score)
- `test_reranker_weighted_negative_scores.phpt` — negative IP score normalization
- `test_reranker_rrf_custom_rank_constant.phpt` — custom rank constant effect on combined scores
- `test_reranker_weighted_empty_weights.phpt` — empty weights edge cases (constructor + setter)
- Added `test_reranker_in_query.phpt` — integration test for `queryWithReranker()` with RRF
- Fixed PHP_FLOAT_MIN bug detection logic in `test_reranker_weighted_float_min_bug.phpt`
- Each test uses `try-finally` with `uniqid()` temp directory and cleanup
- All 9 reranker tests pass with 100% success rate

- **SMELL-013: Migrated all classes to `CrazyGoat\ZVec\` namespace with PSR-4 autoloading** (#94)
- All library classes now live under `CrazyGoat\ZVec\` namespace
- Global class names preserved via `class_alias()` for backward compatibility
Expand Down
4 changes: 2 additions & 2 deletions tests/test_reranker_in_query.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,13 @@ try {
exec("rm -rf " . escapeshellarg($path));
}
?>
--EXPECT--
--EXPECTF--
Test 1: Query without reranker
- Got 5 ZVecDoc results

Test 2: Query with RRF reranker (two-stage retrieval)
- Got 3 ZVecRerankedDoc results
- First result: doc1 (score: 0.0164)
- First result: doc1 (score: %f)

Test 3: Query with Weighted reranker
- Got 3 ZVecRerankedDoc results
Expand Down
118 changes: 118 additions & 0 deletions tests/test_reranker_rrf_custom_rank_constant.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
--TEST--
RRF ReRanker: custom rank constant changes combined scores
--SKIPIF--
<?php if (!extension_loaded('zvec') && !extension_loaded('ffi')) die('skip Neither zvec extension nor FFI available'); ?>
--FILE--
<?php
require_once __DIR__ . '/../src/ZVec.php';
require_once __DIR__ . '/../src/ZVecRrfReRanker.php';

ZVec::init(logType: ZVec::LOG_CONSOLE, logLevel: ZVec::LOG_WARN);

$path = __DIR__ . '/../test_dbs/reranker_rrf_k_' . uniqid();

try {
// Create collection
$schema = new ZVecSchema('test_rrf_k');
$schema->addVectorFp32('v1', 4, ZVecSchema::METRIC_IP)
->addVectorFp32('v2', 4, ZVecSchema::METRIC_IP);
$collection = ZVec::create($path, $schema);

// Insert docs
$docs = [
(new ZVecDoc('doc1'))->setVectorFp32('v1', [0.9, 0.0, 0.0, 0.0])->setVectorFp32('v2', [0.3, 0.0, 0.0, 0.0]),
(new ZVecDoc('doc2'))->setVectorFp32('v1', [0.8, 0.0, 0.0, 0.0])->setVectorFp32('v2', [0.2, 0.0, 0.0, 0.0]),
(new ZVecDoc('doc3'))->setVectorFp32('v1', [0.1, 0.0, 0.0, 0.0])->setVectorFp32('v2', [0.9, 0.0, 0.0, 0.0]),
];
$collection->insert(...$docs);
$collection->optimize();

$queryVector = [1.0, 0.0, 0.0, 0.0];
$r1 = $collection->query('v1', $queryVector, topk: 3);
$r2 = $collection->query('v2', $queryVector, topk: 3);
$queryResults = ['v1' => $r1, 'v2' => $r2];

// Default rank constant (60)
$defaultReranker = new ZVecRrfReRanker(topn: 3, rankConstant: 60);
$defaultResults = $defaultReranker->rerank($queryResults);

// Custom rank constant (1) — higher RRF scores
$customReranker1 = new ZVecRrfReRanker(topn: 3, rankConstant: 1);
$customResults1 = $customReranker1->rerank($queryResults);

// Custom rank constant (100) — lower RRF scores
$customReranker100 = new ZVecRrfReRanker(topn: 3, rankConstant: 100);
$customResults100 = $customReranker100->rerank($queryResults);

echo "Default (k=60) top-3 combined scores:\n";
foreach ($defaultResults as $r) {
echo " {$r->getPk()}: " . round($r->getCombinedScore(), 6) . "\n";
}

echo "Custom (k=1) top-3 combined scores:\n";
foreach ($customResults1 as $r) {
echo " {$r->getPk()}: " . round($r->getCombinedScore(), 6) . "\n";
}

echo "Custom (k=100) top-3 combined scores:\n";
foreach ($customResults100 as $r) {
echo " {$r->getPk()}: " . round($r->getCombinedScore(), 6) . "\n";
}

// Verify k=1 gives highest scores, k=100 gives lowest
if (count($defaultResults) > 0 && count($customResults1) > 0 && count($customResults100) > 0) {
$scoreK1 = $customResults1[0]->getCombinedScore();
$scoreK60 = $defaultResults[0]->getCombinedScore();
$scoreK100 = $customResults100[0]->getCombinedScore();
echo "Score order (k=1 > k=60 > k=100): "
. ($scoreK1 > $scoreK60 && $scoreK60 > $scoreK100 ? 'yes' : 'no') . "\n";
}

// Verify getter/setter for rankConstant
$reranker = new ZVecRrfReRanker(topn: 3);
echo "Default rankConstant: " . $reranker->getRankConstant() . "\n";
$reranker->setRankConstant(42);
echo "After setRankConstant(42): " . $reranker->getRankConstant() . "\n";

// getTopn getter/setter
echo "Default topn: " . $reranker->getTopn() . "\n";
$reranker->setTopn(5);
echo "After setTopn(5): " . $reranker->getTopn() . "\n";

// Verify zero rankConstant edge case — should still work (division by 1/(0+rank))
$zeroK = new ZVecRrfReRanker(topn: 3, rankConstant: 0);
$zeroResults = $zeroK->rerank($queryResults);
echo "Zero rank constant results: " . count($zeroResults) . "\n";
if (count($zeroResults) > 0) {
// k=0 => score = 1/rank for each field
// rank 1: 1/1 = 1.0, rank 2: 1/2 = 0.5
echo "Zero k first combined score: " . round($zeroResults[0]->getCombinedScore(), 6) . "\n";
}

$collection->close();
echo "All custom rank constant tests passed\n";
} finally {
exec("rm -rf " . escapeshellarg($path));
}
?>
--EXPECTF--
Default (k=60) top-3 combined scores:
%s
%s
%s
Custom (k=1) top-3 combined scores:
%s
%s
%s
Custom (k=100) top-3 combined scores:
%s
%s
%s
Score order (k=1 > k=60 > k=100): yes
Default rankConstant: 60
After setRankConstant(42): 42
Default topn: 3
After setTopn(5): 5
Zero rank constant results: 3
Zero k first combined score: %f
All custom rank constant tests passed
48 changes: 48 additions & 0 deletions tests/test_reranker_rrf_empty.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
--TEST--
Reranker RRF: empty query results input returns empty array
--SKIPIF--
<?php if (!extension_loaded('zvec') && !extension_loaded('ffi')) die('skip Neither zvec extension nor FFI available'); ?>
--FILE--
<?php
require_once __DIR__ . '/../src/ZVec.php';
require_once __DIR__ . '/../src/ZVecRrfReRanker.php';

ZVec::init(logType: ZVec::LOG_CONSOLE, logLevel: ZVec::LOG_WARN);

$path = __DIR__ . '/../test_dbs/reranker_rrf_empty_' . uniqid();

try {
$schema = new ZVecSchema('test_rrf_empty');
$schema->addVectorFp32('v', 4, ZVecSchema::METRIC_IP);
$collection = ZVec::create($path, $schema);

$reranker = new ZVecRrfReRanker(topn: 10, rankConstant: 60);

// Edge case: empty associative array (no fields)
$emptyResults = $reranker->rerank([]);
echo count($emptyResults) === 0 ? "PASS: empty main array returns 0 results\n" : "FAIL\n";

// Edge case: field with empty array
$emptyFieldResults = $reranker->rerank(['v' => []]);
echo count($emptyFieldResults) === 0 ? "PASS: field with empty doc array returns 0 results\n" : "FAIL\n";

// Edge case: field with non-array value
$nonArrayResults = $reranker->rerank(['v' => null]);
echo count($nonArrayResults) === 0 ? "PASS: field with null value returns 0 results\n" : "FAIL\n";

// Edge case: non-ZVecDoc objects in array
$nonDocResults = $reranker->rerank(['v' => ['not_a_doc']]);
echo count($nonDocResults) === 0 ? "PASS: non-ZVecDoc elements are filtered out\n" : "FAIL\n";

$collection->close();
echo "All RRF empty input tests passed\n";
} finally {
exec("rm -rf " . escapeshellarg($path));
}
?>
--EXPECT--
PASS: empty main array returns 0 results
PASS: field with empty doc array returns 0 results
PASS: field with null value returns 0 results
PASS: non-ZVecDoc elements are filtered out
All RRF empty input tests passed
98 changes: 98 additions & 0 deletions tests/test_reranker_weighted_empty_weights.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
--TEST--
WeightedReRanker: empty weights edge cases (constructor + setter + rerank skip)
--SKIPIF--
<?php if (!extension_loaded('zvec') && !extension_loaded('ffi')) die('skip Neither zvec extension nor FFI available'); ?>
--FILE--
<?php
require_once __DIR__ . '/../src/ZVec.php';
require_once __DIR__ . '/../src/ZVecWeightedReRanker.php';

ZVec::init(logType: ZVec::LOG_CONSOLE, logLevel: ZVec::LOG_WARN);

$path = __DIR__ . '/../test_dbs/reranker_empty_weights_' . uniqid();

try {
// Test 1: Constructor with empty weights
try {
new ZVecWeightedReRanker([]);
echo "FAIL: Constructor should throw for empty weights\n";
} catch (ZVecException $e) {
echo "PASS: Constructor throws for empty weights\n";
}

// Test 2: setWeights() with empty values
$reranker = new ZVecWeightedReRanker(['field1' => 1.0]);
try {
$reranker->setWeights([]);
echo "FAIL: setWeights should throw for empty array\n";
} catch (ZVecException $e) {
echo "PASS: setWeights throws for empty array\n";
}

// Test 3: setWeights() with valid weights updates the object
$reranker->setWeights(['field_a' => 0.5, 'field_b' => 0.5]);
$weights = $reranker->getWeights();
echo "PASS: setWeights + getWeights count: " . count($weights) . "\n";
echo "PASS: field_a weight: " . $weights['field_a'] . "\n";

// Test 4: getTopn / setTopn
echo "Default topn: " . $reranker->getTopn() . "\n";
$reranker->setTopn(42);
echo "After setTopn(42): " . $reranker->getTopn() . "\n";

// Test 5: getMetricType / setMetricType
echo "Default metric: " . $reranker->getMetricType() . "\n";
$reranker->setMetricType(ZVecSchema::METRIC_L2);
echo "After setMetricType(L2): " . $reranker->getMetricType() . "\n";

// Test 6: Reranker skips fields not in weights
$schema = new ZVecSchema('test_skip');
$schema->addVectorFp32('v', 4, ZVecSchema::METRIC_IP);
$collection = ZVec::create($path, $schema);

$doc = new ZVecDoc('only_doc');
$doc->setVectorFp32('v', [0.5, 0.5, 0.5, 0.5]);
$collection->insert($doc);
$collection->optimize();

$results = $collection->query('v', [1.0, 0.0, 0.0, 0.0], topk: 1);

// Reranker with weight=0 should produce no weighted contribution
$zeroWeight = new ZVecWeightedReRanker(
weights: ['v' => 0.0],
topn: 1,
metricType: ZVecSchema::METRIC_IP
);
$rerankedZero = $zeroWeight->rerank(['v' => $results]);
echo "Zero weight reranked count: " . count($rerankedZero) . "\n";
if (count($rerankedZero) > 0) {
echo "Zero weight combined score: " . $rerankedZero[0]->getCombinedScore() . "\n";
}

// Reranker with negative weight — verify it doesn't crash
$negativeWeight = new ZVecWeightedReRanker(
weights: ['v' => -1.0],
topn: 1,
metricType: ZVecSchema::METRIC_IP
);
$rerankedNeg = $negativeWeight->rerank(['v' => $results]);
echo "Negative weight reranked count: " . count($rerankedNeg) . "\n";

$collection->close();
echo "All empty weights edge case tests passed\n";
} finally {
exec("rm -rf " . escapeshellarg($path));
}
?>
--EXPECT--
PASS: Constructor throws for empty weights
PASS: setWeights throws for empty array
PASS: setWeights + getWeights count: 2
PASS: field_a weight: 0.5
Default topn: 10
After setTopn(42): 42
Default metric: 2
After setMetricType(L2): 1
Zero weight reranked count: 0
Negative weight reranked count: 1
All empty weights edge case tests passed
85 changes: 85 additions & 0 deletions tests/test_reranker_weighted_float_min_bug.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
--TEST--
WeightedReRanker: PHP_FLOAT_MIN initialization bug — verify min/max tracking works
--SKIPIF--
<?php if (!extension_loaded('zvec') && !extension_loaded('ffi')) die('skip Neither zvec extension nor FFI available'); ?>
--FILE--
<?php
require_once __DIR__ . '/../src/ZVec.php';
require_once __DIR__ . '/../src/ZVecWeightedReRanker.php';

ZVec::init(logType: ZVec::LOG_CONSOLE, logLevel: ZVec::LOG_WARN);

$path = __DIR__ . '/../test_dbs/reranker_float_min_' . uniqid();

try {
// Create collection
$schema = new ZVecSchema('test_float_min');
$schema->addVectorFp32('v', 4, ZVecSchema::METRIC_IP);
$collection = ZVec::create($path, $schema);

// Insert docs with varying vectors
$docs = [
(new ZVecDoc('doc1'))->setVectorFp32('v', [0.5, 0.5, 0.5, 0.5]),
(new ZVecDoc('doc2'))->setVectorFp32('v', [0.1, 0.1, 0.1, 0.1]),
];
$collection->insert(...$docs);
$collection->optimize();

// Query with a vector that gives different scores
$results = $collection->query('v', [1.0, 0.0, 0.0, 0.0], topk: 2);
echo "Results: " . count($results) . "\n";

// Verify min/max are properly tracked (not stuck at PHP_FLOAT_MIN / -PHP_FLOAT_MIN)
$scores = array_map(fn($d) => $d->getScore(), $results);
echo "Scores: " . implode(', ', array_map(fn($s) => round($s, 6), $scores)) . "\n";

$minScore = min($scores);
$maxScore = max($scores);
echo "Min score: " . $minScore . "\n";
echo "Max score: " . $maxScore . "\n";

// With proper PHP_FLOAT_MAX initialization, min and max should be the actual scores
// If PHP_FLOAT_MIN was incorrectly used, min would still be ~2.2E-308
$tolerance = 1e-10;
// If min was stuck at PHP_FLOAT_MIN (~2.2E-308), it would be a tiny positive number,
// not the actual minimum score. Check that min was properly updated to real score value.
$stuckAtPhpFloatMin = ($minScore < 1e-100);
echo "min stuck at PHP_FLOAT_MIN: " . ($stuckAtPhpFloatMin ? 'yes' : 'no') . "\n";

// Feed through WeightedReRanker
$reranker = new ZVecWeightedReRanker(
weights: ['v' => 1.0],
topn: 2,
metricType: ZVecSchema::METRIC_IP
);
$reranked = $reranker->rerank(['v' => $results]);
echo "Reranked count: " . count($reranked) . "\n";

// Combined scores should reflect proper normalization
if (count($reranked) >= 2) {
echo "Combined scores: " . round($reranked[0]->getCombinedScore(), 4) . ", "
. round($reranked[1]->getCombinedScore(), 4) . "\n";
// With IP normalization: (score - min) / range
// doc1 should have higher score (closer to query [1,0,0,0] since [0.5,0.5,0.5,0.5] has dot product 0.5)
// doc2 has dot product 0.1 with query [1,0,0,0]
echo "Best doc: " . $reranked[0]->getPk() . " (combined: " . round($reranked[0]->getCombinedScore(), 4) . ")\n";
echo "Worst doc: " . $reranked[1]->getPk() . " (combined: " . round($reranked[1]->getCombinedScore(), 4) . ")\n";
}

$collection->close();
echo "All PHP_FLOAT_MIN bug tests passed\n";
} finally {
exec("rm -rf " . escapeshellarg($path));
}
?>
--EXPECTF--
Results: 2
Scores: %s
Min score: %f
Max score: %f
min stuck at PHP_FLOAT_MIN: no
Reranked count: 2
Combined scores: %f, %f
Best doc: doc1 (combined: %f)
Worst doc: doc2 (combined: %f)
All PHP_FLOAT_MIN bug tests passed
Loading
Loading