SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / WordIteratorTest.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
 *
 * This file contains unit tests of the IndexDocumentBundleTable class
 *
 * @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\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\library\CrawlConstants as CC;
use seekquarry\yioop\library\storage_formats\IndexDocumentBundle;
use seekquarry\yioop\library\IndexManager;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\library\index_bundle_iterators\GroupIterator;
use seekquarry\yioop\library\index_bundle_iterators\IntersectIterator;
use seekquarry\yioop\library\index_bundle_iterators\UnionIterator;
use seekquarry\yioop\library\index_bundle_iterators\WordIterator;

/**
 * WordIteratorTest checks WordIterator, which walks the documents of an
 * IndexDocumentBundle that hold one word, and IntersectIterator, which
 * joins such walks to find the documents holding several words.
 */
 class WordIteratorTest extends UnitTest
{
    /**
     * Name of IndexDocumentBundle used to store test documents
     */
    const TEST_BUNDLE = "IndexData200000000";
    /**
     * Prefix of folders for index manager test
     */
    const TEST_DIR = __DIR__ . '/test_files/word_iterator_test';
    /**
     * $index_archive is the IndexDocumentBundle that setUp makes for the
     * cases to add their test pages to.
     * @var object
     */
    public $index_archive;
    /**
     * setUp makes the test folder if it is missing and opens a new
     * IndexDocumentBundle in it, which each case then fills with pages.
     */
    public function setUp()
    {
        IndexDocumentBundle::$temp_folder = C\TEST_DIR . "/test_files";
        $test_bundle = self::TEST_BUNDLE;
        if (!file_exists(self::TEST_DIR)) {
            mkdir(self::TEST_DIR);
        }
        $this->index_archive = new IndexDocumentBundle(self::TEST_DIR .
            "/$test_bundle", false, "TestBundle", 100, 101);
    }
    /**
     * tearDown deletes the test folder and every bundle a case made in it,
     * so the next case starts from an empty folder.
     */
    public function tearDown()
    {
        array_map("unlink", glob(C\TEST_DIR .
            "/test_files/signal_totals_*") ?: []);
        IndexDocumentBundle::$temp_folder = C\TEMP_DIR;
        $model = new Model();
        $model->db->unlinkRecursive(self::TEST_DIR);
    }
    /**
     * findTermTestCase stores 10 pages in the bundle. The word be is on
     * every page, and each of the words or0 to or9 is on one page. The case
     * checks that a WordIterator's currentDocsWithWord gives 10 pages for
     * be and 1 page for each of the others.
     */
    public function findTermTestCase()
    {
        IndexManager::clearCache();
        $index_archive = $this->index_archive;
        $dictionary = $index_archive->dictionary;
        $keys = [];
        for ($i = 0; $i < 10; $i++) {
            $keys[$i] = $this->docidFromIntKeys($i, $i, $i);
            $docs[] = [
                CC::DOC_ID => $keys[$i],
                CC::SUMMARY =>
                    [
                        CC::DESCRIPTION => "to$i be or$i not$i to$i be...",
                        CC::HASH => str_pad("$i", 8, "0", STR_PAD_LEFT),
                        CC::TITLE => "Some$i Shakespeare$i Play$i",
                        CC::URL => "https://www.somewhere$i.com/"
                    ],
                CC::PAGE => "Page $i",
            ];
        }
        $num_docs = count($docs);
        $index_archive->addPages($docs, $num_docs);
        $index_archive->updateDictionary();
        $index_archive->forceSave();
        $word_iterator = new WordIterator(L\canonicalTerm("be"),
            self::TEST_DIR . "/" . self::TEST_BUNDLE, true, null, 10);
        $current_doc_with_word = $word_iterator->currentDocsWithWord();
        $current_doc_with_word = (is_array($current_doc_with_word)) ?
            $current_doc_with_word : [];
        $this->assertEqual(count($current_doc_with_word), 10,
            "Number of occurrences of 'be' correct");
        for ($i = 0; $i < 10; $i++) {
            $word_iterator = new WordIterator(L\canonicalTerm("or$i"),
                self::TEST_DIR . "/". self::TEST_BUNDLE, true, null, 10);
            $current_doc_with_word = $word_iterator->currentDocsWithWord();
            $current_doc_with_word = (is_array($current_doc_with_word)) ?
                $current_doc_with_word : [];
            $this->assertEqual(count($current_doc_with_word), 1,
                "Number of occurrences of 'or$i' correct");
        }
    }
    /**
     * joinFindsDocumentAfterPartitionChangeTestCase checks that joining two
     * words finds a document in a later partition when one word's walk had
     * to leave an earlier partition to catch up with the other. Partitions
     * hold four pages: in the first, beta is on the first and fourth and
     * alpha on the second. The second partition's
     * first page holds both words, and it is the only page that does, so
     * the join of alpha and beta must find it. Before the walk forgot the
     * last partition's document index on moving on, alpha reported itself
     * past that page and the join ended with nothing.
     */
    public function joinFindsDocumentAfterPartitionChangeTestCase()
    {
        IndexManager::clearCache();
        $bundle_path = self::TEST_DIR . "/IndexData200000001";
        $index_archive = new IndexDocumentBundle($bundle_path, false,
            "TestBundle", 4, 5);
        $descriptions = ["beta", "alpha", "gamma", "beta", "alpha beta",
            "gamma", "gamma", "gamma", "gamma", "gamma"];
        $docs = [];
        foreach ($descriptions as $i => $description) {
            $docs[] = [
                CC::DOC_ID => $this->docidFromIntKeys($i, $i, $i),
                CC::SUMMARY => [
                    CC::DESCRIPTION => $description,
                    CC::HASH => str_pad("$i", 8, "0", STR_PAD_LEFT),
                    CC::TITLE => "Page$i",
                    CC::URL => "https://www.somewhere$i.com/"],
                CC::PAGE => "Page $i"];
        }
        $index_archive->addPages($docs, count($docs));
        $index_archive->updateDictionary();
        $index_archive->forceSave();
        $iterators = [];
        foreach (["alpha", "beta"] as $word) {
            $iterators[] = new WordIterator(L\canonicalTerm($word),
                $bundle_path, true, null, 10);
        }
        $join = new IntersectIterator($iterators, [0, 1]);
        $found = $join->currentDocsWithWord();
        $found = is_array($found) ? $found : [];
        $this->assertEqual(count($found), 1,
            "the join of alpha and beta finds the one page holding both");
    }
    /**
     * BATCH_PAGES_PER_PARTITION is how many pages go into each partition
     * of the bundle batchesReachEveryPartitionTestCase builds.
     */
    const BATCH_PAGES_PER_PARTITION = 2;
    /**
     * BATCH_ROWS is how many dictionary rows, one a partition, the word
     * iterators of batchesReachEveryPartitionTestCase read in one batch.
     */
    const BATCH_ROWS = 2;
    /**
     * BATCH_PARTITIONS is how many partitions batchesReachEveryPartition
     * TestCase fills. The last is the active one, so a word on every
     * partition has three dictionary rows: one full batch of BATCH_ROWS,
     * then a batch of one row that the active partition's row fills.
     */
    const BATCH_PARTITIONS = 4;
    /**
     * batchesReachEveryPartitionTestCase checks that a word in more
     * partitions than one batch of dictionary rows holds is read in every
     * partition, alone and in a join. BATCH_PARTITIONS partitions of
     * BATCH_PAGES_PER_PARTITION pages are read BATCH_ROWS rows at a
     * time, rather than NUM_DISTINCT_GENERATIONS. The made-up word zorp
     * is on every even page, and the made-up word quib is on two of
     * those, one in the first partition and one in the last; common
     * English words would be dropped as stop words. A walk of zorp must
     * reach every page it is on, and the join of zorp and quib must find
     * both pages. Before the active partition's row was kept out of all
     * but a word's last batch, a walk went from the first batch to the
     * active partition and never read the partitions between. Before a
     * batch holding the active partition's row was taken as the word's
     * last, a batch that row filled was followed by one more, which gave
     * the active partition's row again, and the walk read that
     * partition's pages twice.
     */
    public function batchesReachEveryPartitionTestCase()
    {
        IndexManager::clearCache();
        $bundle_path = self::TEST_DIR . "/IndexData200000002";
        $index_archive = new IndexDocumentBundle($bundle_path, false,
            "TestBundle", self::BATCH_PAGES_PER_PARTITION, 25);
        $num_pages = self::BATCH_PAGES_PER_PARTITION *
            self::BATCH_PARTITIONS;
        $rare_pages = [0, $num_pages - 2];
        $docs = [];
        for ($i = 0; $i < $num_pages; $i++) {
            $description = ($i % 2 == 1) ? "flim" :
                (in_array($i, $rare_pages) ? "zorp quib" : "zorp");
            $docs[] = [
                CC::DOC_ID => $this->docidFromIntKeys($i, $i, $i),
                CC::SUMMARY => [
                    CC::DESCRIPTION => $description,
                    CC::HASH => str_pad("$i", 8, "0", STR_PAD_LEFT),
                    CC::TITLE => "Page$i",
                    CC::URL => "https://www.somewhere$i.com/"],
                CC::PAGE => "Page $i"];
        }
        $index_archive->addPages($docs, count($docs));
        $index_archive->updateDictionary();
        $index_archive->forceSave();
        $walk = new WordIterator(L\canonicalTerm("zorp"), $bundle_path,
            true, null, 10);
        $walk->generation_batch_size = self::BATCH_ROWS;
        $walk->loadGenerationBatch(0);
        $walk->reset();
        $this->assertTrue($walk->batch_full,
            "zorp has more partitions than one batch of rows");
        $walked = 0;
        while (is_array($block = $walk->currentDocsWithWord()) &&
            $walked <= $num_pages) {
            $walked += count($block);
            $walk->advance();
        }
        $this->assertEqual($walked, $num_pages / 2,
            "a walk of zorp reaches every page it is on");
        $iterators = [];
        foreach (["zorp", "quib"] as $word) {
            $iterator = new WordIterator(L\canonicalTerm($word),
                $bundle_path, true, null, 10);
            $iterator->generation_batch_size = self::BATCH_ROWS;
            $iterator->loadGenerationBatch(0);
            $iterator->reset();
            $iterators[] = $iterator;
        }
        $join = new IntersectIterator($iterators, [0, 1]);
        $found = 0;
        while (is_array($block = $join->currentDocsWithWord()) &&
            $found <= count($rare_pages)) {
            $found += count($block);
            $join->advance();
        }
        $this->assertEqual($found, count($rare_pages),
            "the join of zorp and quib finds both pages");
    }
    /**
     * walkReadsEveryPostingOnceTestCase walks a word the way UnionIterator
     * does, calling findDocsWithWord and then advance, in blocks of one and
     * of ten, from first page to last and from last to first. Forty pages
     * go into one partition, and the made-up word zeep is on twenty-five of
     * them. Each walk must read the twenty-five postings once each.
     * advanceSeenDocs counted the postings left from where the read had
     * already moved to, so an ascending walk in blocks of one skipped a
     * partition's last posting, and one in blocks of ten read some
     * postings twice. A descending block could start before the list.
     */
    public function walkReadsEveryPostingOnceTestCase()
    {
        IndexManager::clearCache();
        $bundle_path = self::TEST_DIR . "/IndexData200000003";
        $index_archive = new IndexDocumentBundle($bundle_path, false,
            "TestBundle", 100, 101);
        $docs = [];
        $zeep_pages = 0;
        for ($i = 0; $i < 40; $i++) {
            $has_word = ($i % 8 != 3 && $i % 8 != 6 && $i % 8 != 7);
            $zeep_pages += $has_word ? 1 : 0;
            $docs[] = [
                CC::DOC_ID => $this->docidFromIntKeys($i, $i, $i),
                CC::SUMMARY => [
                    CC::DESCRIPTION => $has_word ? "zeep flim" : "flim",
                    CC::HASH => str_pad("$i", 8, "0", STR_PAD_LEFT),
                    CC::TITLE => "Page$i",
                    CC::URL => "https://www.somewhere$i.com/"],
                CC::PAGE => "Page $i"];
        }
        $index_archive->addPages($docs, count($docs));
        $index_archive->updateDictionary();
        $index_archive->forceSave();
        foreach ([[1, WordIterator::ASCENDING], [10, WordIterator::ASCENDING],
            [1, WordIterator::DESCENDING], [10, WordIterator::DESCENDING]]
            as [$block_size, $direction]) {
            $walk = new WordIterator(L\canonicalTerm("zeep"), $bundle_path,
                true, null, $block_size, $direction);
            $read = [];
            while (is_array($block = $walk->findDocsWithWord()) &&
                count($read) <= 2 * $zeep_pages) {
                foreach (array_keys($block) as $doc_key) {
                    $read[] = $doc_key;
                }
                $walk->advance();
            }
            $this->assertEqual(count($read), $zeep_pages, "a walk in " .
                "blocks of $block_size, direction $direction, reads " .
                "$zeep_pages postings");
            $this->assertEqual(count(array_unique($read)), $zeep_pages,
                "a walk in blocks of $block_size, direction $direction, " .
                "reads each page once");
        }
    }
    /**
     * newestCopyIsScoredTestCase checks that the results show the newest
     * copy of a page, found in a partition already merged into the
     * dictionary. Ten text pages go into partitions of four. Page 6 is a
     * newer copy of page 0's address without the word zorp, and page 5 a
     * newer copy of page 1's address with it. The results of a
     * GroupIterator over a walk of zorp must leave out page 0's address
     * and show page 1's address as page 5 alone. The newest copy is
     * looked for by GroupIterator::newestCopy, for result pages only.
     */
    public function newestCopyIsScoredTestCase()
    {
        IndexManager::clearCache();
        $bundle_path = self::TEST_DIR . "/IndexData200000004";
        $index_archive = new IndexDocumentBundle($bundle_path, false,
            "TestBundle", 4, 5);
        $addresses = [0, 1, 2, 3, 4, 1, 0, 7, 8, 9];
        $descriptions = ["zorp", "zorp", "flim", "flim", "flim", "zorp",
            "flim", "flim", "flim", "flim"];
        $docs = [];
        $doc_ids = [];
        foreach ($addresses as $i => $address) {
            $url = "https://www.somewhere$address.com/";
            /* chr(56) marks a text page, the kind whose newest copy is
               looked for */
            $doc_ids[$i] = L\crawlHash($url, true) .
                str_pad("$i", 8, "0", STR_PAD_LEFT) . chr(56) .
                str_pad("$address", 7, "0", STR_PAD_LEFT);
            $docs[] = [
                CC::DOC_ID => $doc_ids[$i],
                CC::SUMMARY => [
                    CC::DESCRIPTION => $descriptions[$i],
                    CC::HASH => str_pad("$i", 8, "0", STR_PAD_LEFT),
                    CC::TITLE => "Page$i",
                    CC::URL => $url],
                CC::PAGE => "Page $i"];
        }
        $index_archive->addPages($docs, count($docs));
        $index_archive->updateDictionary();
        $index_archive->forceSave();
        $results = new GroupIterator(new WordIterator(
            L\canonicalTerm("zorp"), $bundle_path, true, null, 10));
        $results->latest_copy_fraction = 1;
        $keys = [];
        $block = $results->nextDocsWithWord();
        foreach (is_array($block) ? $block : [] as $page) {
            foreach ($page[CC::SUMMARY_OFFSET] ?? [] as $offset) {
                if (count($offset) > 2) {
                    $keys[] = $offset[1];
                }
            }
        }
        $this->assertFalse(in_array($doc_ids[0], $keys),
            "page 0 is left out, as its newest copy lacks zorp");
        $this->assertFalse(in_array($doc_ids[1], $keys),
            "page 1 is not shown as it was");
        $this->assertEqual($keys, [$doc_ids[5]],
            "page 1's address is shown once, as page 5");
    }
    /**
     * docidFromIntKeys makes the document id of a test page from three
     * small numbers, padding each to the length a real hash has, so a case
     * can give each page an id of its own.
     * @param int $i_hash_url an integer that is supposed to be used
     *      to correspond to the hash of a url component of a doc_id
     * @param int $j_hash_page an integer that is supposed to be used
     *      to correspond to the hash of a web page component of a doc_id
     * @param int $k_hash_host an integer that is supposed to be used
     *      to correspond to the hash of a url's host component of a doc_id
     * @param bool $is_doc whether to make a doc_id for a link to doc item or
     *      for a document
     * @return string doc_id to associate with that integer
     */
    protected function docidFromIntKeys($i_hash_url, $j_hash_page,
        $k_hash_host, $is_doc = true)
    {
            $doc_or_link = ($is_doc) ? "d" : "l";
            return str_pad("$i_hash_url", 8, "0", STR_PAD_LEFT) .
                str_pad("$j_hash_page", 8, "0", STR_PAD_LEFT) .
                $doc_or_link .
                str_pad("$k_hash_host", 7, "0", STR_PAD_LEFT);
    }
    /**
     * positionsReadCapTestCase checks that a positions read whose length
     * is past the maximum readable posting
     * length is treated as corrupt and skipped, reading nothing, while a
     * length within bounds reads and decodes the positions normally. This
     * guards the positions read the way the posting-string read is guarded,
     * so a corrupt length cannot make the server allocate a huge block.
     */
    public function positionsReadCapTestCase()
    {
        $positions = [3, 7, 11];
        $encoded = L\encodePositionList($positions);
        $positions_file = C\TEST_DIR . "/test_files/positions_cap_test.bin";
        file_put_contents($positions_file, $encoded);
        $handle = fopen($positions_file, "r");
        $iterator = (new \ReflectionClass(WordIterator::class))->
            newInstanceWithoutConstructor();
        $within = $iterator->getPositionsList(["POSITIONS_LEN" =>
            strlen($encoded), "POSITIONS_OFFSET" => 0,
            "FREQUENCY" => count($positions)], strlen($encoded) + 1000,
            $handle);
        $this->assertEqual($positions, $within,
            "a positions length within bounds reads and decodes");
        $before = memory_get_usage();
        $skipped = $iterator->getPositionsList(["POSITIONS_LEN" =>
            C\MAX_POSTING_READ_LEN + 1, "POSITIONS_OFFSET" => 0,
            "FREQUENCY" => 1], C\MAX_POSTING_READ_LEN * 4, $handle);
        $after = memory_get_usage();
        $this->assertEqual([], $skipped,
            "a positions length past the cap is skipped");
        $this->assertTrue($after - $before < C\MAX_POSTING_READ_LEN,
            "the skipped read allocates nothing large");
        fclose($handle);
        unlink($positions_file);
    }
    /**
     * divergenceScoreTestCase checks the field-weighted divergence score:
     * with no occurrences it is log2(1 + o), where o is the expected
     * occurrences of the term per document, it rises as the weighted
     * frequency rises, and it never reaches log2(1 + 1/o), so a host match
     * on top of a title match still adds to the score.
     */
    public function divergenceScoreTestCase()
    {
        $occurrences_per_doc = 0.0607;
        $this->assertEqual(round(WordIterator::divergenceScore(
            $occurrences_per_doc, 0), 6), round(log(1 + $occurrences_per_doc,
            2), 6), "no occurrences give log2(1 + o)");
        $body = WordIterator::divergenceScore($occurrences_per_doc, 0.3);
        $title = WordIterator::divergenceScore($occurrences_per_doc, 5 + 0.3);
        $host_and_title = WordIterator::divergenceScore($occurrences_per_doc,
            6 + 5 + 0.3);
        $this->assertTrue($body < $title && $title < $host_and_title,
            "more weighted occurrences score higher");
        $this->assertTrue($host_and_title <
            log(1 + 1 / $occurrences_per_doc, 2),
            "the score stays below log2(1 + 1/o)");
    }
    /**
     * fieldKeyScoredFromOtherKeyTestCase checks that a union in which a
     * word's field key part is paired with the word's other key part
     * scores the same pages as a union that walks the field key itself,
     * while reading fewer postings. Twenty-four English pages go into
     * partitions of four. The made-up word zorp is on every page, and in
     * the title of the even ones, so those are filed under zorp's field
     * key. The made-up word quib is on five pages spread over the
     * partitions. The block holds five pages, so quib's five fill it, and
     * the field key part then only adds relevance to them. Both unions are
     * read for two blocks, the second showing the field key part was left
     * where its own walk would have left it.
     */
    public function fieldKeyScoredFromOtherKeyTestCase()
    {
        IndexManager::clearCache();
        $bundle_path = self::TEST_DIR . "/IndexData200000003";
        $index_archive = new IndexDocumentBundle($bundle_path, false,
            "TestBundle", 4, 5);
        $quib_pages = [1, 6, 11, 16, 21];
        $docs = [];
        for ($i = 0; $i < 24; $i++) {
            $docs[] = [
                CC::DOC_ID => $this->docidFromIntKeys($i, $i, $i),
                CC::SUMMARY => [
                    CC::DESCRIPTION => in_array($i, $quib_pages) ?
                        "zorp flim quib" : "zorp flim",
                    CC::HASH => str_pad("$i", 8, "0", STR_PAD_LEFT),
                    CC::TITLE => ($i % 2 == 0) ? "Zorp$i zorp" : "Page$i",
                    CC::URL => "https://www.somewhere$i.com/",
                    CC::LANG => "en-US"],
                CC::PAGE => "Page $i"];
        }
        $index_archive->addPages($docs, count($docs));
        $index_archive->updateDictionary();
        $index_archive->forceSave();
        $keys = [L\combinedTerm("quib", "en", "all"),
            L\combinedTerm("zorp", "en", "all" .
            IndexDocumentBundle::FIELD_TERM_MARK),
            L\combinedTerm("zorp", "en", "all")];
        $blocks = [];
        $reads = [];
        foreach ([[], [1 => 2]] as $walk => $field_parts) {
            $iterators = [];
            foreach ($keys as $key) {
                $iterators[] = new WordIterator($key, $bundle_path, true,
                    null, 10);
            }
            $union = new UnionIterator($iterators, $bundle_path, [],
                $field_parts);
            $union->results_per_block = count($quib_pages);
            L\AnalyticsManager::set(L\AnalyticsManager::TIMINGS, []);
            for ($block = 0; $block < 2; $block++) {
                $found = $union->currentDocsWithWord();
                $blocks[$walk][$block] = [];
                foreach (is_array($found) ? $found : [] as $key => $page) {
                    $blocks[$walk][$block][bin2hex($key)] =
                        round($page[CC::RELEVANCE], 9);
                }
                ksort($blocks[$walk][$block]);
                $union->advance();
            }
            $reads[$walk] = L\AnalyticsManager::get(
                L\AnalyticsManager::TIMINGS)["postings reads"][0] ?? 0;
        }
        $this->assertEqual(count($blocks[0][0]), count($quib_pages),
            "the first block holds the quib pages");
        $this->assertTrue(count($blocks[0][1]) > 0,
            "the second block holds pages");
        $this->assertEqual($blocks[1], $blocks[0],
            "both unions give the same pages with the same relevance");
        $iterators = [];
        foreach ([$keys[0], $keys[2]] as $key) {
            $iterators[] = new WordIterator($key, $bundle_path, true,
                null, 10);
        }
        $union = new UnionIterator($iterators, $bundle_path);
        $union->results_per_block = count($quib_pages);
        $found = $union->currentDocsWithWord();
        $without_field = [];
        foreach (is_array($found) ? $found : [] as $key => $page) {
            $without_field[bin2hex($key)] = round($page[CC::RELEVANCE], 9);
        }
        ksort($without_field);
        $this->assertTrue($without_field != $blocks[0][0],
            "the field key adds relevance to the quib pages it holds");
        $this->assertTrue($reads[1] < $reads[0],
            "the paired union reads fewer postings");
    }
}