SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / UnionIteratorTest.php⤓
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\tests;

use seekquarry\yioop\library\CrawlConstants as CC;
use seekquarry\yioop\library\index_bundle_iterators\IndexBundleIterator;
use seekquarry\yioop\library\index_bundle_iterators\UnionIterator;
use seekquarry\yioop\library\UnitTest;

/**
 * UnionIteratorTest checks UnionIterator's merge of the documents its
 * iterators find.
 */
class UnionIteratorTest extends UnitTest
{
    /**
     * setUp has nothing to make; each case makes its own iterators.
     */
    public function setUp()
    {
    }
    /**
     * tearDown has nothing to remove.
     */
    public function tearDown()
    {
    }
    /**
     * exhaustedIteratorIsSkippedTestCase unions an iterator whose two
     * documents fill the block with one that has none left. Once the block
     * is full, each later iterator's position was compared with every
     * document as a (generation, offset) pair, and an exhausted iterator's
     * -1 gave the warning "Trying to access array offset on int" in
     * genDocOffsetCmp, logged 98 times on yioop.com between 26 and 30
     * September.
     */
    public function exhaustedIteratorIsSkippedTestCase()
    {
        $found = new UnionStubIterator([[0, 1], [0, 2]]);
        $exhausted = new UnionStubIterator([]);
        /* UnionIterator orders its iterators by how many documents they
           claim; claiming more puts the exhausted one after the full
           block, where the comparison was. */
        $exhausted->num_docs = 10;
        $union = new UnionIterator([$found, $exhausted], "stub");
        $union->results_per_block = 2;
        $warnings = [];
        set_error_handler(function ($number, $message) use (&$warnings) {
            $warnings[] = $message;
            return true;
        });
        $pages = $union->findDocsWithWord();
        restore_error_handler();
        $this->assertEqual($warnings, [],
            "an exhausted iterator gives no warning");
        $this->assertEqual(is_array($pages) ? count($pages) : 0, 2,
            "both documents of the other iterator are found");
    }
    /**
     * partThatDoesNotAdvanceStepsOnTestCase unions a short list, which
     * fills the block's two accumulators, with a part whose advance to a
     * given document does not move. Asking it again and again is how a
     * query can run forever; the union must step the part on by one
     * document instead, so the part is asked to advance to a document
     * only a few times, and the block still holds both documents.
     */
    public function partThatDoesNotAdvanceStepsOnTestCase()
    {
        $short = new UnionStubIterator([[0, 1], [0, 5]]);
        $stuck = new UnionStuckStubIterator([[0, 2], [0, 3], [0, 7]]);
        $union = new UnionIterator([$short, $stuck], "stub");
        $union->results_per_block = 2;
        $pages = $union->findDocsWithWord();
        $this->assertTrue($stuck->target_calls < 10,
            "the part was asked to advance " . $stuck->target_calls .
            " times, not again and again");
        $this->assertEqual(is_array($pages) ? count($pages) : 0, 2,
            "both documents of the short list are in the block");
    }
    /**
     * sharedDocumentIsCountedOnceTestCase unions two iterators that both
     * find the document at offset (0, 1), with room in the block for every
     * document. The later iterator adds its relevance to that document's
     * accumulator. It must then move on: left where it was, it offered
     * the same document again as a new one, and its rank and relevance
     * were counted twice. The first iterator's document at (0, 3), after
     * the end of the later list, must also stay in the block.
     */
    public function sharedDocumentIsCountedOnceTestCase()
    {
        $first = new UnionStubIterator([[0, 1], [0, 3]]);
        $later = new UnionStubIterator([[0, 1], [0, 2]]);
        $later->num_docs = 10;
        $union = new UnionIterator([$first, $later], "stub");
        $union->results_per_block = 10;
        $pages = $union->findDocsWithWord();
        $shared = $pages["doc0-1"] ?? [];
        $this->assertEqual(is_array($pages) ? count($pages) : 0, 3,
            "the three documents are found");
        $this->assertEqual($shared[CC::RELEVANCE] ?? 0, 2,
            "the shared document has the relevance of both iterators");
        $this->assertEqual($shared[CC::DOC_RANK] ?? 0, 1,
            "and its rank once");
    }
    /**
     * partsAreTakenShortestFirstTestCase checks that a UnionIterator takes
     * its parts shortest posting list first, as term at a time query
     * processing takes its terms. Three parts of 2, 1 and 3 documents
     * were left in that order.
     */
    public function partsAreTakenShortestFirstTestCase()
    {
        $middle = new UnionStubIterator([[0, 1], [0, 2]]);
        $shortest = new UnionStubIterator([[0, 1]]);
        $longest = new UnionStubIterator([[0, 1], [0, 2], [0, 3]]);
        $union = new UnionIterator([$middle, $shortest, $longest], "stub");
        $this->assertTrue($union->index_bundle_iterators[0] === $shortest &&
            $union->index_bundle_iterators[1] === $middle &&
            $union->index_bundle_iterators[2] === $longest,
            "parts go shortest posting list first");
    }
    /**
     * joinedWordOpensAccumulatorsTestCase unions a part for tik and tok,
     * whose two documents fill a block of two, with a longer part for
     * tiktok holding an earlier document of its own. The tiktok part
     * holds a word of the first part, so it may still open new
     * accumulators, and its document is found. Given no words, the
     * union does not know the part is joined, and the document stays
     * out.
     */
    public function joinedWordOpensAccumulatorsTestCase()
    {
        $split = new UnionStubIterator([[0, 2], [0, 3]]);
        $joined = new UnionStubIterator([[0, 1], [0, 2], [0, 4]]);
        $union = new UnionIterator([$split, $joined], "stub",
            [["tik", "tok"], ["tiktok"]]);
        $union->results_per_block = 2;
        $this->assertEqual($union->joined_word_parts, [1],
            "the tiktok part is noted as joined");
        $pages = $union->findDocsWithWord();
        $this->assertTrue(isset($pages["doc0-1"]),
            "the document only the tiktok part has is found");
        $split->reset();
        $joined->reset();
        $union = new UnionIterator([$split, $joined], "stub");
        $union->results_per_block = 2;
        $pages = $union->findDocsWithWord();
        $this->assertTrue(!isset($pages["doc0-1"]),
            "without the words it stays out");
    }
    /**
     * thresholdKeepsExpectedNewAccumulatorsUnderQuotaTestCase gives a
     * part 15 postings read of 30, five at each of relevance steps 10, 20
     * and 30, and 7 new accumulators left. The rest of the list is
     * expected to hold as many postings again, so five more reach step
     * 30 and ten more reach step 20. Only step 30 keeps the expected
     * number under 7.
     */
    public function thresholdKeepsExpectedNewAccumulatorsUnderQuotaTestCase()
    {
        $short = new UnionStubIterator([[0, 1]]);
        $long = new UnionStubIterator([[0, 1]]);
        $long->num_docs = 30;
        $union = new UnionIterator([$short, $long], "stub");
        $union->postings_seen[1] = 15;
        $union->relevance_counts[1] = [10 => 5, 20 => 5, 30 => 5];
        $union->workOutThreshold(1, 7);
        $this->assertEqual($union->thresholds[1], 30,
            "the threshold is step 30");
    }
    /**
     * recrawledDocumentIsCountedOnceTestCase unions a word's field key and
     * own key over a page crawled twice, in partitions 0 and 6. Each key
     * holds a posting for each copy, and WordIterator gives both postings
     * the key of the latest copy. The union added the two copies' rank and
     * relevance, so the page scored as two pages; a song page on yioop.com
     * outranked yahoo.com for the query yahoo this way. The page must
     * have its relevance from the two keys once, and its rank once.
     */
    public function recrawledDocumentIsCountedOnceTestCase()
    {
        $field = new UnionStubIterator([[0, 1], [6, 2]], ["song", "song"]);
        $own = new UnionStubIterator([[0, 1], [0, 5], [6, 2]],
            ["song", "other", "song"]);
        $union = new UnionIterator([$field, $own], "stub");
        $union->results_per_block = 10;
        $pages = $union->findDocsWithWord();
        $song = $pages["song"] ?? [];
        $this->assertEqual(is_array($pages) ? count($pages) : 0, 2,
            "the song page and the other page are found");
        $this->assertEqual($song[CC::RELEVANCE] ?? 0, 2,
            "the song page has the relevance of both keys once");
        $this->assertEqual($song[CC::DOC_RANK] ?? 0, 1,
            "and its rank once");
    }
}
/**
 * UnionStubIterator hands UnionIteratorTest a fixed list of documents, one
 * at a time, as a word's iterator would.
 */
class UnionStubIterator extends IndexBundleIterator
{
    /**
     * The (generation, offset) pairs of the documents, in order
     * @var array
     */
    public $offsets;
    /**
     * Index in $offsets of the current document
     * @var int
     */
    public $position = 0;
    /**
     * Keys of the documents at $offsets, where they are not made from the
     * offset
     * @var array
     */
    public $keys;
    /**
     * Makes an iterator over the documents at $offsets.
     *
     * @param array $offsets (generation, offset) pairs, in order
     * @param array $keys the documents' keys, in the same order; a
     *      document without one is keyed by its offset
     */
    public function __construct($offsets, $keys = [])
    {
        $this->offsets = $offsets;
        $this->keys = $keys;
        $this->num_docs = count($offsets);
    }
    /**
     * Goes back to the first document.
     */
    public function reset()
    {
        $this->position = 0;
    }
    /**
     * Moves to the next document, or to the first at or after
     * $gen_doc_offset when one is given.
     *
     * @param array $gen_doc_offset a (generation, offset) pair, or null
     */
    public function advance($gen_doc_offset = null)
    {
        $this->position++;
        while ($gen_doc_offset !== null &&
            isset($this->offsets[$this->position]) &&
            $this->genDocOffsetCmp($this->offsets[$this->position],
            $gen_doc_offset) < 0) {
            $this->position++;
        }
    }
    /**
     * Gives the current document's (generation, offset) pair, or -1 when
     * none is left.
     *
     * @return mixed the pair, or -1
     */
    public function currentGenDocOffsetWithWord()
    {
        return $this->offsets[$this->position] ?? -1;
    }
    /**
     * Gives the current document as the one page found, or -1 when none
     * is left.
     *
     * @return mixed the page keyed by its key, or -1
     */
    public function findDocsWithWord()
    {
        $offset = $this->offsets[$this->position] ?? null;
        if ($offset === null) {
            return -1;
        }
        $key = $this->keys[$this->position] ?? "doc" . implode("-", $offset);
        return [$key => [CC::KEY => $key, CC::DOC_RANK => 1,
            CC::RELEVANCE => 1, CC::SCORE => 2, CC::IS_DOC => true,
            CC::GENERATION => $offset[0]]];
    }
}
/**
 * UnionStuckStubIterator is a UnionStubIterator whose advance to a
 * given document does not move, the first STUCK_CALLS times it is asked,
 * as a word's iterator that fails to find a later posting would. It counts
 * those asks, so UnionIteratorTest can see whether the union kept asking.
 */
class UnionStuckStubIterator extends UnionStubIterator
{
    /**
     * How many asks to advance to a given document are left unmoved before
     * the iterator moves as UnionStubIterator does
     */
    const STUCK_CALLS = 1000;
    /**
     * $target_calls counts the asks to advance to a given document
     * @var int
     */
    public $target_calls = 0;
    /**
     * advance moves to the next document when no document is given. When
     * one is given, it stays where it is for the first STUCK_CALLS asks,
     * then moves as UnionStubIterator does.
     *
     * @param array $gen_doc_offset a (generation, offset) pair, or null
     */
    public function advance($gen_doc_offset = null)
    {
        if ($gen_doc_offset !== null) {
            $this->target_calls++;
            if ($this->target_calls <= self::STUCK_CALLS) {
                return;
            }
        }
        parent::advance($gen_doc_offset);
    }
}