Files
jze9 476682741e fix(L1): обрезать отпечатки равномерно по тексту, а не произвольно
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>
2026-09-05 19:13:01 +05:00

136 lines
5.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Юнит-тесты алгоритма 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 # шаг ровный