winnow() возвращает set, поэтому list(fp)[:LIMIT] брал случайное подмножество: у длинного документа целые куски оставались без отпечатков, и списывание именно из них не находилось. Обнаружено при разборе того, почему фрагмент статьи PMC не искался. Добавлены winnow_ordered() — отпечатки в порядке появления в тексте, и sample_evenly() — выборка каждого n-го элемента вместо первых N. Применено в add_document и store_full_text. 7 тестов на главное свойство: выборка растянута по всей длине документа, шаг ровный, порядок сохранён. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
5.3 KiB
Python
136 lines
5.3 KiB
Python
"""Юнит-тесты алгоритма Winnowing (уровень 1 — точные/частичные совпадения)."""
|
||
|
||
from app.algorithms.winnowing import (
|
||
compute_similarity,
|
||
get_ngrams,
|
||
hash_ngram,
|
||
jaccard_similarity,
|
||
sample_evenly,
|
||
winnow,
|
||
winnow_ordered,
|
||
)
|
||
|
||
# Достаточно длинный текст, чтобы окно Winnowing реально отработало
|
||
LONG = (
|
||
"машинное обучение позволяет извлекать закономерности из больших объёмов "
|
||
"данных без явного программирования каждого правила вручную аналитиком"
|
||
)
|
||
|
||
|
||
def test_get_ngrams_basic():
|
||
assert get_ngrams(["a", "b", "c", "d"], k=2) == ["a b", "b c", "c d"]
|
||
|
||
|
||
def test_get_ngrams_too_short_is_empty():
|
||
assert get_ngrams(["a", "b"], k=5) == []
|
||
|
||
|
||
def test_hash_ngram_is_deterministic():
|
||
assert hash_ngram("одна и та же строка") == hash_ngram("одна и та же строка")
|
||
|
||
|
||
def test_hash_ngram_fits_signed_int64():
|
||
# Хэш обязан умещаться в PostgreSQL BIGINT (signed 64-bit)
|
||
for s in ("a", "тест", "one two three four five", "𝔘𝔫𝔦𝔠𝔬𝔡𝔢"):
|
||
h = hash_ngram(s)
|
||
assert -(2**63) <= h <= 2**63 - 1
|
||
|
||
|
||
def test_winnow_short_text_is_empty():
|
||
assert winnow("три слова тут", k=5) == set()
|
||
|
||
|
||
def test_winnow_identical_text_identical_fingerprint():
|
||
assert winnow(LONG) == winnow(LONG)
|
||
|
||
|
||
def test_winnow_is_case_insensitive():
|
||
assert winnow(LONG) == winnow(LONG.upper())
|
||
|
||
|
||
def test_jaccard_identical_is_one():
|
||
fp = winnow(LONG)
|
||
assert fp # непустой отпечаток
|
||
assert jaccard_similarity(fp, fp) == 1.0
|
||
|
||
|
||
def test_jaccard_disjoint_is_zero():
|
||
assert jaccard_similarity({1, 2, 3}, {4, 5, 6}) == 0.0
|
||
|
||
|
||
def test_jaccard_empty_is_zero():
|
||
assert jaccard_similarity(set(), {1, 2}) == 0.0
|
||
|
||
|
||
def test_compute_similarity_identical_documents_is_one():
|
||
# Ключевая гарантия L1: копия документа детектится как 100% совпадение
|
||
assert compute_similarity(LONG, LONG) == 1.0
|
||
|
||
|
||
def test_compute_similarity_unrelated_documents_is_low():
|
||
other = (
|
||
"рецепт домашнего хлеба на закваске требует терпения муки воды соли "
|
||
"и тёплого места для медленного подъёма теста в течение ночи"
|
||
)
|
||
assert compute_similarity(LONG, other) < 0.1
|
||
|
||
|
||
def test_compute_similarity_partial_overlap_is_between():
|
||
modified = LONG + " добавлен ещё один совершенно новый хвост предложения здесь"
|
||
sim = compute_similarity(LONG, modified)
|
||
assert 0.0 < sim < 1.0
|
||
|
||
|
||
# ─── Обрезка отпечатков по лимиту ────────────────────────────────────────────
|
||
# Регрессия: winnow() возвращает set, и list(fp)[:limit] брал произвольное
|
||
# подмножество — у длинного документа целые куски оставались без покрытия,
|
||
# и списывание из них не находилось.
|
||
|
||
|
||
def test_winnow_ordered_matches_winnow_by_content():
|
||
"""Тот же набор отпечатков, что и у winnow, только с порядком."""
|
||
assert set(winnow_ordered(LONG)) == winnow(LONG)
|
||
|
||
|
||
def test_winnow_ordered_has_no_duplicates():
|
||
ordered = winnow_ordered(LONG)
|
||
assert len(ordered) == len(set(ordered))
|
||
|
||
|
||
def test_winnow_ordered_follows_text_order():
|
||
"""Отпечатки начала текста идут раньше отпечатков продолжения."""
|
||
tail = " совершенно другой хвост про выпечку хлеба и закваску в тёплой печи"
|
||
ordered = winnow_ordered(LONG + tail)
|
||
head_prints = set(winnow_ordered(LONG))
|
||
positions = [i for i, h in enumerate(ordered) if h in head_prints]
|
||
# Отпечатки первой половины сосредоточены в начале списка, а не разбросаны
|
||
assert max(positions) < len(ordered)
|
||
assert positions[0] == 0
|
||
|
||
|
||
def test_sample_evenly_keeps_everything_under_limit():
|
||
items = [1, 2, 3]
|
||
assert sample_evenly(items, 10) == items
|
||
assert sample_evenly(items, 0) == items # 0 = без ограничения
|
||
|
||
|
||
def test_sample_evenly_respects_limit():
|
||
items = list(range(1000))
|
||
assert len(sample_evenly(items, 100)) == 100
|
||
|
||
|
||
def test_sample_evenly_covers_whole_document():
|
||
"""Главное свойство: выборка растянута по всей длине, а не обрезана с начала."""
|
||
items = list(range(1000))
|
||
sampled = sample_evenly(items, 10)
|
||
assert sampled[0] == 0
|
||
assert sampled[-1] >= 900 # хвост документа тоже покрыт
|
||
assert sampled == sorted(sampled) # порядок сохранён
|
||
|
||
|
||
def test_sample_evenly_spreads_uniformly():
|
||
items = list(range(100))
|
||
sampled = sample_evenly(items, 10)
|
||
gaps = [b - a for a, b in zip(sampled, sampled[1:], strict=False)]
|
||
assert max(gaps) - min(gaps) <= 1 # шаг ровный
|