/ tests / RangeDecoderTest.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\av_processing\OggDemuxer;
use seekquarry\yioop\library\av_processing\OpusPacket;
use seekquarry\yioop\library\av_processing\RangeDecoder;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;

/**
 * Checks that the reader of Opus's compressed numbers gives back
 * exactly what was put in.
 *
 * Nothing else about a recording can be read until this can, and a
 * single wrong step makes everything after it meaningless rather than
 * merely wrong, so this is checked harder than the parts around it. The
 * cases put long runs of numbers in and take them out again, including
 * the two arrangements that are easy to get wrong: a run of bytes of
 * all ones, where a carry has to ripple back through bytes already
 * written, and a piece written from both ends at once, where the two
 * ends have to meet without treading on each other.
 *
 * The last case takes real recordings and reads the opening of every
 * piece in them, which is a check the round trip cannot give: it says
 * the reader agrees with software that had no sight of it.
 *
 * @author Chris Pollett
 */
class RangeDecoderTest extends UnitTest
{
    /**
     * How many bytes to give a written piece in these cases
     */
    const ROOM = 4000;
    /**
     * How many numbers to put in and take out in the longer cases
     */
    const RUN_LENGTH = 400;
    /**
     * A starting point for the made up numbers, so a failing case
     * fails the same way every time it is run
     */
    const SEED = 20260803;
    /**
     * Where the quarter second recordings sit
     */
    const RECORDINGS = ["/test_files/tiny_recording_ogg.txt",
        "/test_files/tiny_recording_webm.txt"];
    /**
     * Sets the made up numbers off from the same place every run
     */
    public function setUp()
    {
        /* The writer used to check the reader is test support rather
           than library code, so Yioop does not find it by itself and
           it is named here. */
        if (!class_exists("seekquarry\\yioop\\tests\\RangeEncoder")) {
            require_once C\PARENT_DIR . "/tests/test_files/RangeEncoder.php";
        }
        mt_srand(self::SEED);
    }
    /**
     * Nothing needs clearing away after these cases
     */
    public function tearDown()
    {
    }
    /**
     * A run of numbers written by likelihood should come back in the
     * same order with the same values
     */
    public function numbersByLikelihoodTestCase()
    {
        $total = 64;
        $written = [];
        $writer = new RangeEncoder(self::ROOM);
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            $which = mt_rand(0, $total - 1);
            $written[] = $which;
            $writer->encode($which, $which + 1, $total);
        }
        $piece = $writer->finish();
        $this->assertFalse($writer->overflowed, "the piece had room");
        $reader = new RangeDecoder($piece);
        $matched = 0;
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            $found = $reader->decode($total);
            $reader->update($found, $found + 1, $total);
            if ($found == $written[$i]) {
                $matched++;
            }
        }
        $this->assertEqual($matched, self::RUN_LENGTH,
            "every number came back as it went in");
    }
    /**
     * Numbers of very different likelihoods should still come back
     * correctly, and a likely number should cost far less room than an
     * unlikely one
     */
    public function unevenLikelihoodsTestCase()
    {
        $total = 1024;
        $common_end = 1000;
        $writer = new RangeEncoder(self::ROOM);
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            $writer->encode(0, $common_end, $total);
        }
        $likely_size = strlen(rtrim($writer->finish(), "\0"));
        $writer = new RangeEncoder(self::ROOM);
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            $writer->encode($common_end, $total, $total);
        }
        $unlikely_size = strlen(rtrim($writer->finish(), "\0"));
        $this->assertTrue($unlikely_size > $likely_size * 4,
            "an unlikely number costs much more room than a likely one");
        $writer = new RangeEncoder(self::ROOM);
        $written = [];
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            $rare = (mt_rand(0, 99) == 0);
            $written[] = $rare;
            if ($rare) {
                $writer->encode($common_end, $total, $total);
            } else {
                $writer->encode(0, $common_end, $total);
            }
        }
        $reader = new RangeDecoder($writer->finish());
        $matched = 0;
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            $found = $reader->decode($total);
            $was_rare = ($found >= $common_end);
            if ($was_rare) {
                $reader->update($common_end, $total, $total);
            } else {
                $reader->update(0, $common_end, $total);
            }
            if ($was_rare == $written[$i]) {
                $matched++;
            }
        }
        $this->assertEqual($matched, self::RUN_LENGTH,
            "a mix of likely and unlikely numbers came back right");
    }
    /**
     * Numbers written from a table of likelihoods counting downwards
     * should come back the same way
     */
    public function numbersFromTableTestCase()
    {
        $table = [200, 150, 90, 40, 10, 0];
        $shift = 8;
        $written = [];
        $writer = new RangeEncoder(self::ROOM);
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            $which = mt_rand(0, count($table) - 1);
            $written[] = $which;
            $writer->encodeFromTable($which, $table, $shift);
        }
        $reader = new RangeDecoder($writer->finish());
        $matched = 0;
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            if ($reader->decodeFromTable($table, $shift) == $written[$i]) {
                $matched++;
            }
        }
        $this->assertEqual($matched, self::RUN_LENGTH,
            "every number from the table came back as it went in");
    }
    /**
     * Yes and no answers of a given chance should come back the same
     * way
     */
    public function yesNoAnswersTestCase()
    {
        $shift = 3;
        $written = [];
        $writer = new RangeEncoder(self::ROOM);
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            $answer = (mt_rand(0, 7) == 0) ? 1 : 0;
            $written[] = $answer;
            $writer->encodeBit($answer, $shift);
        }
        $reader = new RangeDecoder($writer->finish());
        $matched = 0;
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            if ($reader->decodeBit($shift) == $written[$i]) {
                $matched++;
            }
        }
        $this->assertEqual($matched, self::RUN_LENGTH,
            "every answer came back as it went in");
    }
    /**
     * Values written as they are go at the far end of the piece and
     * come back from there, so a piece holding both kinds is read from
     * both ends at once and the two must not tread on each other
     */
    public function bothEndsAtOnceTestCase()
    {
        $total = 32;
        $raw_width = 7;
        $by_likelihood = [];
        $as_they_are = [];
        $writer = new RangeEncoder(self::ROOM);
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            $which = mt_rand(0, $total - 1);
            $plain = mt_rand(0, (1 << $raw_width) - 1);
            $by_likelihood[] = $which;
            $as_they_are[] = $plain;
            $writer->encode($which, $which + 1, $total);
            $writer->encodeRawBits($plain, $raw_width);
        }
        $piece = $writer->finish();
        $this->assertFalse($writer->overflowed, "the piece had room");
        $reader = new RangeDecoder($piece);
        $matched = 0;
        $plain_matched = 0;
        for ($i = 0; $i < self::RUN_LENGTH; $i++) {
            $found = $reader->decode($total);
            $reader->update($found, $found + 1, $total);
            if ($found == $by_likelihood[$i]) {
                $matched++;
            }
            if ($reader->decodeRawBits($raw_width) == $as_they_are[$i]) {
                $plain_matched++;
            }
        }
        $this->assertEqual($matched, self::RUN_LENGTH,
            "the numbers read from the front all came back");
        $this->assertEqual($plain_matched, self::RUN_LENGTH,
            "the values read from the back all came back");
    }
    /**
     * A long run of the most likely number makes the writer produce
     * bytes of all ones, and a later number can carry into them, which
     * has to ripple back through bytes already written
     */
    public function carryRipplesBackTestCase()
    {
        $total = 4096;
        $writer = new RangeEncoder(self::ROOM);
        /* Choosing the top of the range over and over drives the
           written bytes towards all ones, which is what leaves a carry
           with somewhere to ripple. */
        for ($i = 0; $i < 60; $i++) {
            $writer->encode($total - 1, $total, $total);
        }
        for ($i = 0; $i < 20; $i++) {
            $writer->encode(0, 1, $total);
        }
        $piece = $writer->finish();
        $reader = new RangeDecoder($piece);
        $matched = 0;
        for ($i = 0; $i < 60; $i++) {
            $found = $reader->decode($total);
            $reader->update($total - 1, $total, $total);
            if ($found == $total - 1) {
                $matched++;
            }
        }
        for ($i = 0; $i < 20; $i++) {
            $found = $reader->decode($total);
            $reader->update(0, 1, $total);
            if ($found == 0) {
                $matched++;
            }
        }
        $this->assertEqual($matched, 80,
            "the carry rippled back without disturbing what it passed");
    }
    /**
     * Numbers where every value was as likely as every other should
     * come back the same, at both the narrow and the wide sizes, since
     * a wide one is written in two parts
     */
    public function evenlyLikelyNumbersTestCase()
    {
        $limits = [2, 17, 256, 257, 1000, 65536];
        $matched = 0;
        $tried = 0;
        foreach ($limits as $limit) {
            $written = [];
            $writer = new RangeEncoder(self::ROOM);
            for ($i = 0; $i < 50; $i++) {
                $value = mt_rand(0, $limit - 1);
                $written[] = $value;
                $writer->encodeNumber($value, $limit);
            }
            $reader = new RangeDecoder($writer->finish());
            for ($i = 0; $i < 50; $i++) {
                $tried++;
                if ($reader->decodeNumber($limit) == $written[$i]) {
                    $matched++;
                }
            }
        }
        $this->assertEqual($matched, $tried,
            "every evenly likely number came back at every size tried");
    }
    /**
     * A piece cut short should be read without complaint rather than
     * running off the end, since a damaged recording should give up
     * what it can
     */
    public function shortPieceIsReadSafelyTestCase()
    {
        $total = 16;
        $writer = new RangeEncoder(self::ROOM);
        for ($i = 0; $i < 100; $i++) {
            $writer->encode(3, 4, $total);
        }
        $piece = substr($writer->finish(), 0, 6);
        $reader = new RangeDecoder($piece);
        $ran = 0;
        for ($i = 0; $i < 200; $i++) {
            $found = $reader->decode($total);
            $reader->update($found, $found + 1, $total);
            $ran++;
        }
        $this->assertEqual($ran, 200,
            "reading past the end of a cut piece does not fall over");
    }
    /**
     * Reading the opening of every piece in two real recordings should
     * run through without the reader losing its place, which says it
     * agrees with software that never saw it
     */
    public function realRecordingsAreReadTestCase()
    {
        $pieces = [];
        $stored = file_get_contents(C\PARENT_DIR .
            "/tests" . self::RECORDINGS[0]);
        $name = sys_get_temp_dir() . "/range_decoder_test.ogg";
        file_put_contents($name, base64_decode($stored));
        $reader = OggDemuxer::fromName($name);
        foreach ($reader->packets() as $piece) {
            if (strncmp($piece->data, "Opus", 4) != 0) {
                $pieces[] = $piece->data;
            }
        }
        unlink($name);
        $this->assertTrue(count($pieces) > 5, "there were pieces to read");
        $read = 0;
        $all_in_range = true;
        foreach ($pieces as $piece) {
            $opus = OpusPacket::fromString($piece);
            foreach ($opus->stretches as $stretch) {
                if (strlen($stretch) < 2) {
                    continue;
                }
                $entropy = new RangeDecoder($stretch);
                /* The reader must always leave its range in the band it
                   is defined over. A reader that has lost its place
                   leaves it outside that band, so checking it after a
                   run of reads catches a fault without needing to know
                   what the numbers meant. */
                for ($i = 0; $i < 16; $i++) {
                    $entropy->decodeBit(1);
                    if ($entropy->range <= RangeDecoder::CODE_BOTTOM ||
                        $entropy->range > 0xFFFFFFFF) {
                        $all_in_range = false;
                    }
                    if ($entropy->value >= $entropy->range) {
                        $all_in_range = false;
                    }
                }
                $read++;
            }
        }
        $this->assertTrue($read > 5, "stretches of real sound were read");
        $this->assertTrue($all_in_range,
            "the reader kept its place through every stretch");
    }
}
X