############################################################################### # Copyright (C) 2026, Leo Galambos # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # 3. Neither the name of the copyright holder nor the names of its contributors # may be used to endorse or promote products derived from this software # without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. ############################################################################### """Acceptance tests for the radixor Python extension. Run after building the extension: cd python/ pip install maturin pytest maturin develop --release pytest -q The synthetic tests are self-contained and deterministic (no network, no bundled data). """ from __future__ import annotations import gzip import inspect from pathlib import Path from radixor import Stemmer def _write_gz_dict(lines: list[str], tmp_path: Path) -> str: """Write a gzipped TSV dictionary into pytest's temporary directory.""" path = tmp_path / "dictionary.gz" with gzip.open(path, "wt", encoding="utf-8", newline="\n") as gz: gz.write("\n".join(lines)) return str(path) # Synthetic, deterministic pipeline tests. def test_backward_suffix_stemming_roundtrip(tmp_path: Path): # stemvariant... ; backward (suffix) stemming. dict_lines = [ "run\trunning\truns\tran", "cat\tcats", "walk\twalking\twalks\twalked", ] path = _write_gz_dict(dict_lines, tmp_path) s = Stemmer(path=path, backward=True, store_original=True) # Every listed variant must stem back to its canonical stem. assert s.stem("running") == "run" assert s.stem("runs") == "run" assert s.stem("ran") == "run" assert s.stem("cats") == "cat" assert s.stem("walking") == "walk" assert s.stem("walked") == "walk" # store_original: the stem itself is recognised (no-op patch). assert s.stem("run") == "run" assert s.stem("cat") == "cat" def test_store_original_controls_bare_stem_identity(tmp_path: Path): # With a single rule and store_original=True, the stem maps to itself via # the no-op patch, and the "cat" vs "cats" terminals carry different values # so the trie does NOT collapse to a universal rule. path = _write_gz_dict(["cat\tcats"], tmp_path) s_keep = Stemmer(path=path, backward=True, store_original=True) assert s_keep.stem("cats") == "cat" assert s_keep.stem("cat") == "cat" # With store_original=False, only the single rule cats->cat is present. # Radixor's always-on uniform-subtree contraction generalizes that lone # rule to ALL input (this is the intended generalization behavior), so the # bare stem is rewritten by the same delete-one-suffix rule. s_drop = Stemmer(path=path, backward=True, store_original=False) assert s_drop.stem("cats") == "cat" assert s_drop.stem("cat") == "ca" # generalized: delete final char assert s_drop.stem("dogs") == "dog" # rule applies to unseen input too def test_unknown_word_returns_none(tmp_path: Path): path = _write_gz_dict(["cat\tcats"], tmp_path) s = Stemmer(path=path, backward=True) assert s.stem("zzzunknown") is None def test_pystemmer_scalar_api_returns_original_word_for_unknown(tmp_path: Path): path = _write_gz_dict(["cat\tcats"], tmp_path) s = Stemmer(path=path, backward=True) assert s.stemWord("cats") == "cat" assert s.stemWord("ZzZUnknown") == "ZzZUnknown" # The original Radixor API keeps its existing missing-value contract. assert s.stem("ZzZUnknown") is None def test_pystemmer_batch_api_returns_original_words_for_unknowns(tmp_path: Path): path = _write_gz_dict(["run\trunning\truns", "cat\tcats"], tmp_path) s = Stemmer(path=path, backward=True) words = ["running", "Nope", "cats", "QzXqZx"] assert s.stemWords(words) == ["run", "Nope", "cat", "QzXqZx"] assert s.stem_batch(words) == ["run", None, "cat", None] def test_pystemmer_batch_cache_does_not_change_original_api(tmp_path: Path): path = _write_gz_dict(["cat\tcats"], tmp_path) s = Stemmer(path=path, backward=True, cache_size=100) assert s.stemWords(["Unknown", "cats", "Unknown"]) == ["Unknown", "cat", "Unknown"] assert s.stem_batch(["Unknown", "cats", "Unknown"]) == [None, "cat", None] def test_wrapper_forwards_default_cache_size_and_zero_opt_out(monkeypatch): import radixor constructor_calls = [] class RecordingStemmerCore: def __init__(self, *args): constructor_calls.append(args) monkeypatch.setattr(radixor, "StemmerCore", RecordingStemmerCore) radixor.Stemmer(path="model.rxc") radixor.Stemmer(path="model.rxc", cache_size=0) assert constructor_calls[0][-1] == 10_000 assert constructor_calls[1][-1] == 0 def test_native_constructor_default_cache_size(): from radixor._radixor import StemmerCore assert inspect.signature(StemmerCore).parameters["cache_size"].default == 10_000 def test_pystemmer_language_name_alias(): import radixor as StemmerModule # Only the dependency/import line changes from PyStemmer's conventional # ``import Stemmer; Stemmer.Stemmer("english")`` usage. s = StemmerModule.Stemmer("english") assert s.stemWord("running") == "run" assert s.stemWords(["running", "unknown_word"]) == ["run", "unknown_word"] def test_case_is_lowercased(tmp_path: Path): path = _write_gz_dict(["cat\tcats"], tmp_path) s = Stemmer(path=path, backward=True) assert s.stem("CATS") == "cat" assert s.stem("Cats") == "cat" def test_batch_matches_scalar(tmp_path: Path): path = _write_gz_dict(["run\trunning\truns", "cat\tcats"], tmp_path) s = Stemmer(path=path, backward=True) words = ["running", "runs", "cats", "nope", "run"] assert s.stem_batch(words) == [s.stem(w) for w in words] def test_compile_roundtrip_matches_from_text(tmp_path: Path): import os import radixor dict_lines = [ "run\trunning\truns\tran", "cat\tcats", "walk\twalking\twalks\twalked", ] src = _write_gz_dict(dict_lines, tmp_path) out = src + ".rxc" radixor.compile(src, out, backward=True) from_text = Stemmer(path=src, backward=True) from_compiled = Stemmer(compiled=out) words = [ "running", "runs", "ran", "cats", "walking", "walked", "run", "cat", "walk", "unknownzzz", ] assert from_compiled.stem_batch(words) == from_text.stem_batch(words) # The compiled artifact uses the gzip-wrapped EGTR v7 stream format. import gzip with gzip.open(out, "rb") as fh: assert fh.read(4) == b"EGTR" os.unlink(out) def test_cache_does_not_change_results(tmp_path: Path): path = _write_gz_dict(["run\trunning\truns", "cat\tcats"], tmp_path) plain = Stemmer(path=path, backward=True, cache_size=0) cached = Stemmer(path=path, backward=True, cache_size=1000) words = ["running", "runs", "cats", "nope", "run", "running", "cats"] assert cached.stem_batch(words) == plain.stem_batch(words) # Repeated lookups exercise the cache-hit path. assert cached.stem_batch(["running"] * 5) == ["run"] * 5 def test_default_cache_is_shared_across_scalar_and_batch_apis(tmp_path: Path): root = "cacheable-root-value" variant = "cacheable-root-values" path = _write_gz_dict([f"{root}\t{variant}"], tmp_path) cached = Stemmer(path=path, backward=True) first = cached.stem(variant) assert first == root assert cached.stem(variant) is first assert cached.stemWord(variant) is first assert cached.stem_batch([variant])[0] is first assert cached.stemWords([variant])[0] is first disabled = Stemmer(path=path, backward=True, cache_size=0) uncached_first = disabled.stem(variant) uncached_second = disabled.stem(variant) assert uncached_first == uncached_second == root assert uncached_first is not uncached_second def test_full_cache_keeps_existing_entries_without_admitting_new_ones(tmp_path: Path): roots = ("first-cacheable-root", "second-cacheable-root") variants = tuple(f"{root}-value" for root in roots) path = _write_gz_dict( [f"{root}\t{variant}" for root, variant in zip(roots, variants)], tmp_path ) stemmer = Stemmer(path=path, backward=True, cache_size=1) first = stemmer.stem(variants[0]) assert stemmer.stem(variants[0]) is first uncached = stemmer.stem(variants[1]) assert uncached == roots[1] assert stemmer.stem(variants[1]) == uncached assert stemmer.stem(variants[1]) is not uncached assert stemmer.stem(variants[0]) is first def test_lowercase_false_assumes_prelowered(tmp_path: Path): path = _write_gz_dict(["cat\tcats"], tmp_path) s = Stemmer(path=path, backward=True, lowercase=False) assert s.stem("cats") == "cat" # already-lowercase input works assert s.stem("CATS") is None # not lowercased -> no match def test_forward_prefix_stemming(tmp_path: Path): # Forward traversal handles prefix-oriented morphology (RTL languages). path = _write_gz_dict(["kitab\talkitab\talkitabu"], tmp_path) s = Stemmer(path=path, backward=False, store_original=True) assert s.stem("alkitab") == "kitab" assert s.stem("alkitabu") == "kitab" assert s.stem("kitab") == "kitab" def test_stem_all_returns_candidates(tmp_path: Path): path = _write_gz_dict(["run\trunning", "runn\trunning"], tmp_path) s = Stemmer(path=path, backward=True, store_original=True) alls = s.stem_all("running") # "running" maps to both "run" and "runn"; both must be reachable. assert set(alls) >= {"run", "runn"} # Installed standard-model smoke test. def test_installed_english_compiled_model(): s = Stemmer("en") assert s.stem_batch(["running", "walked", "cats"]) == ["run", "walk", "cat"]