feat(site-finder): per-competitor relevance_weight (#949 PR B)

Adds deterministic horizon-aware relevance_weight ∈[0,1] to get_competitors
(ТЗ §9.1): weighted mean of geo_proximity (0.30), class_similarity (0.25),
price_similarity (0.20), stage_at_horizon (0.25). stage_at_horizon projects
each competitor's sell-out to horizon_months (request param, default 12) so a
competitor sold-out by our launch ranks lower. class_similarity uses a domrf
class-ladder vs the local dominant class (no target class yet). Orders by
relevance_weight DESC (distance still present + tie-break). relevance_breakdown
(4 sub-scores) exposed for explainability (§16).

Additive: all legacy Competitor fields intact, new fields Optional; zero
frontend consumers of /competitors (UI uses /analyze). sold-count folded into
existing price query (no extra db.execute, #227 regression green). Graceful:
thin data → neutral 0.5. 57 new pure-helper tests. Completes #949.
This commit is contained in:
Light1YT 2026-06-03 00:45:28 +05:00
parent 45d61ecff0
commit 2450159fe9
3 changed files with 600 additions and 15 deletions

View file

@ -181,6 +181,10 @@ class CompetitorsRequest(BaseModel):
time_window: TimeWindow = "last_quarter"
obj_class_filter: ObjClassFilter | None = None
exclude_obj_ids: list[int] = Field(default_factory=list)
# #949 PR B (ТЗ §9.1): горизонт запуска (мес.) для horizon-aware
# relevance_weight. Конкурент, который к нашему запуску будет распродан,
# менее релевантен (см. stage_at_horizon в competitors.py).
horizon_months: int = Field(default=12, ge=1, le=60)
class Competitor(BaseModel):
@ -200,6 +204,12 @@ class Competitor(BaseModel):
velocity_per_month: float
avg_price_per_m2: float | None
is_active: bool
# #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] —
# взвешенная комбинация geo_proximity / class_similarity / price_similarity /
# stage_at_horizon. None только если расчёт недоступен (backward-safe).
# relevance_breakdown — explainability: 4 саб-скора (каждый 0..1).
relevance_weight: float | None = None
relevance_breakdown: dict[str, float] | None = None
class CompetitorsSummary(BaseModel):

View file

@ -17,6 +17,7 @@ objective_complex_mapping. LEFT JOIN гарантирует velocity=0 (не о
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -40,6 +41,215 @@ _TIME_WINDOW_MONTHS: dict[str, float] = {
# site_status значения, считающиеся «активными»
_ACTIVE_STATUSES = frozenset({"sales", "construction"})
# ──────────────────────────────────────────────────────────────────────────────
# #949 PR B (ТЗ §9.1, §16) — relevance_weight: детерминированная (без LLM)
# взвешенная оценка релевантности конкурента 0..1 + explainability-breakdown.
# ──────────────────────────────────────────────────────────────────────────────
# Веса саб-скоров для итогового relevance_weight (named constants — tunable).
# Сумма = 1.0; geo и stage_at_horizon — главные сигналы (близость + «будет ли
# конкурировать к нашему запуску»), class/price — корректировки сопоставимости.
_W_GEO: float = 0.30
_W_CLASS: float = 0.25
_W_PRICE: float = 0.20
_W_STAGE: float = 0.25
# Нейтральный саб-скор при недостатке данных (graceful: не штрафуем и не
# завышаем конкурента, по которому нет сигнала — ТЗ §15 spirit).
_NEUTRAL: float = 0.5
# Детерминированный порядок класса жилья (домрф): эконом < комфорт < комфорт+ <
# бизнес < бизнес+ < премиум/элит. class_similarity считается по дистанции в этом
# порядке. Ключи нормализуются через _normalize_class (lower + синонимы домрф/EN,
# чтобы 'Комфорт'/'comfort'/'комфорт-класс' попадали в один индекс).
_CLASS_ORDER: dict[str, int] = {
"эконом": 0,
"стандарт": 0, # домрф канон для эконома (см. domrf_kn._OBJ_CLASS_PATTERNS)
"типовой": 0,
"комфорт": 1,
"комфорт+": 2,
"бизнес": 3,
"бизнес+": 4,
"премиум": 5,
"элит": 5,
}
# class_similarity по дистанции шагов в _CLASS_ORDER: 0 шагов (тот же класс) → 1.0,
# 1 шаг (соседний, комфорт↔комфорт+) → 0.6, 2 → 0.3, ≥3 → 0.1.
_CLASS_SIM_BY_STEPS: dict[int, float] = {0: 1.0, 1: 0.6, 2: 0.3}
_CLASS_SIM_FAR: float = 0.1
# Синонимы для нормализации obj_class к ключам _CLASS_ORDER. obj_class в данных
# приходит и кириллицей ('Комфорт'), и латиницей (фильтр API economy/comfort/
# business), и с суффиксами ('комфорт-класс'). Сводим к каноническому ключу.
_CLASS_SYNONYMS: dict[str, str] = {
"economy": "эконом",
"econom": "эконом",
"standard": "стандарт",
"comfort": "комфорт",
"comfortplus": "комфорт+",
"business": "бизнес",
"businessplus": "бизнес+",
"premium": "премиум",
"elite": "элит",
"elit": "элит",
}
def _normalize_class(obj_class: str | None) -> str | None:
"""Привести obj_class к каноническому ключу _CLASS_ORDER.
Терпимо к регистру, латинице (economy/comfort/business из API-фильтра),
суффиксам ('комфорт-класс', '«Бизнес»') и плюс-формам ('комфорт +',
'комфорт плюс' 'комфорт+'). Возвращает None если класс не распознан.
"""
if not obj_class:
return None
s = obj_class.strip().lower()
# Унифицируем плюс-формы до проверки прямого вхождения.
s = s.replace(" плюс", "+").replace("плюс", "+").replace(" +", "+")
if s in _CLASS_ORDER:
return s
if s in _CLASS_SYNONYMS:
return _CLASS_SYNONYMS[s]
# Подстрочный матч по канон-ключам (длинные ключи первыми: 'комфорт+'
# раньше 'комфорт', иначе 'комфорт-класс плюс' схлопнется в 'комфорт').
for key in sorted(_CLASS_ORDER, key=len, reverse=True):
if key in s:
return key
for syn, key in _CLASS_SYNONYMS.items():
if syn in s:
return key
return None
def _geo_proximity(distance_m: float | None, radius_m: float) -> float:
"""geo_proximity ∈ [0,1] — линейный decay расстояния.
1.0 на участке (distance 0), 0.0 на краю радиуса. За радиусом / без
данных нейтрально не нужно: расстояние всегда известно (PostGIS), но
защищаемся от None/нулевого радиуса. clamp в [0,1].
"""
if distance_m is None or radius_m <= 0:
return _NEUTRAL
return max(0.0, min(1.0, 1.0 - distance_m / radius_m))
def _class_similarity(competitor_class: str | None, reference_class: str | None) -> float:
"""class_similarity ∈ [0,1] по дистанции в _CLASS_ORDER.
Тот же класс 1.0, соседний (1 шаг) 0.6, 2 шага 0.3, дальше 0.1.
Если класс конкурента или эталон неизвестен/нераспознан нейтрально 0.5
(нет сигнала о сопоставимости не штрафуем).
reference_class: эталон сравнения. На этой стадии целевой класс участка ещё
не выбран (нет target), поэтому caller передаёт ЛОКАЛЬНЫЙ ДОМИНИРУЮЩИЙ класс
(самый частый среди конкурентов в радиусе) так скор остаётся осмысленным
«насколько конкурент типичен для этой локации».
"""
c = _normalize_class(competitor_class)
r = _normalize_class(reference_class)
if c is None or r is None:
return _NEUTRAL
steps = abs(_CLASS_ORDER[c] - _CLASS_ORDER[r])
return _CLASS_SIM_BY_STEPS.get(steps, _CLASS_SIM_FAR)
def _price_similarity(competitor_ppm2: float | None, median_ppm2: float | None) -> float:
"""price_similarity ∈ [0,1] — близость цены конкурента к локальной медиане.
1 - min(1, |c_price - median| / median): на медиане 1.0, отклонение в
100%+ 0.0. Нет цены конкурента или невалидная медиана нейтрально 0.5.
"""
if competitor_ppm2 is None or not median_ppm2 or median_ppm2 <= 0:
return _NEUTRAL
rel_diff = abs(competitor_ppm2 - median_ppm2) / median_ppm2
return max(0.0, 1.0 - min(1.0, rel_diff))
def _stage_at_horizon(
available: float | None,
velocity_per_month: float | None,
horizon_months: int,
flats_total: float | None,
) -> float:
"""stage_at_horizon ∈ [0,1] — horizon-aware ключевой фактор (ТЗ §9.1).
Проецируем распродажу конкурента на горизонт нашего запуска:
projected_remaining = max(0, available - velocity * horizon_months)
Скор = доля проектируемого остатка от изначального объёма
(projected_remaining / flats_total). Конкурент, у которого к запуску ещё
значимый остаток высокий скор (он БУДЕТ конкурировать); проектируемо
распроданный низкий.
Graceful: если velocity или available/total неизвестны (тонкие данные)
нейтрально 0.5 не переоцениваем и не недооцениваем (ТЗ §15). clamp [0,1].
"""
if available is None or velocity_per_month is None or flats_total is None or flats_total <= 0:
return _NEUTRAL
if velocity_per_month <= 0:
# Нет продаж: остаток не уменьшается — конкурент останется на рынке
# (макс. релевантность по этому фактору).
return max(0.0, min(1.0, available / flats_total))
projected_remaining = max(0.0, available - velocity_per_month * float(horizon_months))
return max(0.0, min(1.0, projected_remaining / flats_total))
def _relevance_weight(geo: float, class_sim: float, price: float, stage: float) -> float:
"""Взвешенное среднее 4 саб-скоров → relevance_weight ∈ [0,1].
Веса named constants (_W_GEO/_W_CLASS/_W_PRICE/_W_STAGE), сумма 1.0.
Все входы предполагаются уже clamped в [0,1]; результат тоже clamp для
устойчивости к будущей подстройке весов.
"""
weighted = geo * _W_GEO + class_sim * _W_CLASS + price * _W_PRICE + stage * _W_STAGE
return max(0.0, min(1.0, weighted))
def _dominant_class(obj_classes: list[str | None]) -> str | None:
"""Локальный доминирующий класс — самый частый нормализованный obj_class.
Используется как эталон class_similarity, когда целевой класс участка ещё
не задан. None если ни один класс не распознан. При ничьей берётся класс с
наименьшим индексом в _CLASS_ORDER (детерминированно, без рандома).
"""
counts: dict[str, int] = {}
for raw in obj_classes:
norm = _normalize_class(raw)
if norm is not None:
counts[norm] = counts.get(norm, 0) + 1
if not counts:
return None
# Сортируем: больше частота → раньше; при равной частоте меньший order-индекс.
return min(counts, key=lambda k: (-counts[k], _CLASS_ORDER[k]))
def _median(values: list[float]) -> float | None:
"""Детерминированная медиана непустого списка. Пусто → None."""
if not values:
return None
ordered = sorted(values)
n = len(ordered)
mid = n // 2
if n % 2 == 1:
return ordered[mid]
return (ordered[mid - 1] + ordered[mid]) / 2.0
def _row_get(row: Any, key: str) -> Any:
"""Безопасно прочитать ключ из row-mapping → None если ключа нет.
Используем ТОЛЬКО __getitem__: и SQLAlchemy RowMapping, и тестовые
MagicMock-строки реализуют его корректно и кидают KeyError на отсутствующий
ключ. (Не используем .get(): на MagicMock это авто-атрибут-заглушка,
возвращающая MagicMock вместо реального значения.) Нужно для backward-safe
чтения новой колонки flats_sold старые моки её просто не отдают."""
try:
return row[key]
except (KeyError, TypeError, IndexError):
return None
# SQL для получения центроида участка
_PARCEL_CENTROID_SQL = text("""
SELECT ST_X(pt) AS lon, ST_Y(pt) AS lat
@ -136,13 +346,23 @@ _COMPETITORS_SQL = text("""
# Фильтр status='sold' убран: поле status в domrf_kn_flats заполнено в ~0.2% строк
# (99.8% NULL) — фильтр давал 0 строк и avg_price_per_m2 всегда None (Issue #112/227).
# AVG по всем квартирам с price_per_m2 IS NOT NULL даёт корректную среднюю цену ЖК.
#
# #949 PR B: добавлена колонка flats_sold (COUNT квартир со status LIKE '%прод%')
# — нужна для stage_at_horizon (available = flat_count - flats_sold). status
# заполнен слабо (~0.2%), поэтому flats_sold чаще будет 0/мало → stage_at_horizon
# падает в нейтраль 0.5 (graceful, см. helper). Колонка добавлена в ТОТ ЖЕ запрос
# (без 4-го db.execute), чтобы не ломать существующие callers/тесты.
_AVG_PRICE_SQL = text("""
SELECT
f.obj_id,
AVG(f.price_per_m2) AS avg_price_per_m2
AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL)
AS avg_price_per_m2,
COUNT(*) FILTER (
WHERE LOWER(COALESCE(f.status, '')) LIKE '%прод%'
OR LOWER(COALESCE(f.status, '')) = 'sold'
) AS flats_sold
FROM domrf_kn_flats f
WHERE f.obj_id = ANY(:obj_ids)
AND f.price_per_m2 IS NOT NULL
GROUP BY f.obj_id
""")
@ -245,22 +465,34 @@ def get_competitors(
obj_ids: list[int] = [int(r["obj_id"]) for r in rows]
# ── 4. Средняя цена м² (graceful — таблица может быть не заполнена) ──────
# ── 4. Средняя цена м² + sold-count (graceful — таблица может быть пуста) ─
# flats_sold нужен для stage_at_horizon (available = flat_count - flats_sold).
# Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2,
# отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage.
avg_price_map: dict[int, float] = {}
sold_count_map: dict[int, int] = {}
try:
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
avg_price_map = {
int(r["obj_id"]): float(r["avg_price_per_m2"])
for r in price_rows
if r["avg_price_per_m2"] is not None
}
for r in price_rows:
oid = int(r["obj_id"])
price = _row_get(r, "avg_price_per_m2")
if price is not None:
avg_price_map[oid] = float(price)
sold = _row_get(r, "flats_sold")
if sold is not None:
sold_count_map[oid] = int(sold)
except Exception:
logger.warning("competitors: avg_price query failed, continuing without prices")
# ── 5. Сборка результата ─────────────────────────────────────────────────
# flats_sold / sold_pct: не доступны из domrf_kn_objects (только flat_count).
# Можно получить через COUNT(domrf_kn_flats WHERE status='sold') —
# отложено за MVP, поля остаются None.
# ── 4b. Локальные эталоны для relevance (медиана цены, доминирующий класс) ─
radius_m = request.radius_km * 1000.0
local_median_ppm2 = _median(list(avg_price_map.values()))
dominant_class = _dominant_class([r["obj_class"] for r in rows])
# ── 5. Сборка результата + relevance_weight (ТЗ §9.1) ────────────────────
# flats_sold: COUNT из domrf_kn_flats по status LIKE '%прод%' (заполнен слабо
# ~0.2%, поэтому чаще 0/None). При known flats_sold считаем sold_pct и
# available для stage_at_horizon; иначе оба поля None и stage → нейтраль 0.5.
competitors: list[Competitor] = []
for r in rows:
obj_id = int(r["obj_id"])
@ -269,6 +501,37 @@ def get_competitors(
site_status = r["site_status"]
is_active = site_status in _ACTIVE_STATUSES if site_status else False
velocity = round(float(r["velocity_per_month"]), 2)
# flats_sold / sold_pct / available — только если sold-count реально есть
# И он осмыслен (не превышает total). Иначе None (graceful, не выдумываем).
flats_sold: int | None = None
sold_pct: float | None = None
available: float | None = None
raw_sold = sold_count_map.get(obj_id)
if raw_sold is not None and flats_total and flats_total > 0 and raw_sold <= flats_total:
flats_sold = raw_sold
sold_pct = round(raw_sold / flats_total * 100.0, 1)
available = float(flats_total - raw_sold)
# ── relevance саб-скоры (каждый 0..1) + взвешенный weight ─────────────
geo = _geo_proximity(float(r["distance_m"]), radius_m)
class_sim = _class_similarity(r["obj_class"], dominant_class)
price = _price_similarity(avg_price_map.get(obj_id), local_median_ppm2)
stage = _stage_at_horizon(
available=available,
velocity_per_month=velocity,
horizon_months=request.horizon_months,
flats_total=float(flats_total) if flats_total else None,
)
relevance_weight = _relevance_weight(geo, class_sim, price, stage)
relevance_breakdown = {
"geo_proximity": round(geo, 3),
"class_similarity": round(class_sim, 3),
"price_similarity": round(price, 3),
"stage_at_horizon": round(stage, 3),
}
competitors.append(
Competitor(
obj_id=obj_id,
@ -280,14 +543,20 @@ def get_competitors(
lng=float(r["longitude"]),
stage=site_status,
flats_total=flats_total,
flats_sold=None,
sold_pct=None,
velocity_per_month=round(float(r["velocity_per_month"]), 2),
flats_sold=flats_sold,
sold_pct=sold_pct,
velocity_per_month=velocity,
avg_price_per_m2=avg_price_map.get(obj_id),
is_active=is_active,
relevance_weight=round(relevance_weight, 3),
relevance_breakdown=relevance_breakdown,
)
)
# ── 5b. Сортировка по relevance_weight DESC (distance остаётся в полях) ────
# tie-break по distance ASC (ближе — раньше), затем obj_id для стабильности.
competitors.sort(key=lambda c: (-(c.relevance_weight or 0.0), c.distance_m, c.obj_id))
# ── 6. Summary ───────────────────────────────────────────────────────────
active_count = sum(1 for c in competitors if c.is_active)
total_velocity = sum(c.velocity_per_month for c in competitors)

View file

@ -0,0 +1,306 @@
"""Unit-тесты relevance_weight конкурентов (#949 PR B, ТЗ §9.1, §16).
Чистые тесты pure-хелперов (без живой БД, без app.main):
_normalize_class нормализация obj_class (RU/EN/суффиксы/plus-формы).
_geo_proximity линейный decay расстояния (parcel1, край радиуса0).
_class_similarity карта по шагам в _CLASS_ORDER (same/adjacent/2/far),
нейтраль 0.5 при неизвестном классе.
_price_similarity близость к локальной медиане; нейтраль при None.
_stage_at_horizon horizon-aware проекция распродажи (sold-outlow,
активныйhigh, тонкие данные0.5).
_relevance_weight взвешенное среднее (named weights, сумма 1.0).
_dominant_class / _median детерминированные эталоны.
Детерминированно, без LLM. Каждый граничный/тонкий кейс проверяет graceful
поведение (нейтраль 0.5 / clamp, никогда не crash).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from app.services.site_finder.competitors import (
_CLASS_ORDER,
_NEUTRAL,
_W_CLASS,
_W_GEO,
_W_PRICE,
_W_STAGE,
_class_similarity,
_dominant_class,
_geo_proximity,
_median,
_normalize_class,
_price_similarity,
_relevance_weight,
_stage_at_horizon,
)
# ── weights sanity ────────────────────────────────────────────────────────────
def test_weights_sum_to_one() -> None:
"""Named-веса саб-скоров образуют выпуклую комбинацию (сумма = 1.0)."""
assert _W_GEO + _W_CLASS + _W_PRICE + _W_STAGE == pytest.approx(1.0)
# ── _normalize_class ──────────────────────────────────────────────────────────
class TestNormalizeClass:
def test_canonical_ru(self) -> None:
assert _normalize_class("комфорт") == "комфорт"
assert _normalize_class("Бизнес") == "бизнес"
def test_case_insensitive(self) -> None:
assert _normalize_class("КОМФОРТ") == "комфорт"
def test_english_synonyms(self) -> None:
# API-фильтр шлёт economy/comfort/business.
assert _normalize_class("economy") == "эконом"
assert _normalize_class("comfort") == "комфорт"
assert _normalize_class("business") == "бизнес"
assert _normalize_class("premium") == "премиум"
def test_plus_forms(self) -> None:
assert _normalize_class("комфорт+") == "комфорт+"
assert _normalize_class("комфорт плюс") == "комфорт+"
assert _normalize_class("Бизнес +") == "бизнес+"
def test_substring_suffix(self) -> None:
# «комфорт-класс», «класс «Бизнес»» из домрф aiDescription.
assert _normalize_class("комфорт-класс") == "комфорт"
assert _normalize_class("класс «Бизнес»") == "бизнес"
def test_plus_substring_not_swallowed_to_base(self) -> None:
# 'комфорт+' должен распознаться раньше 'комфорт' (длинный ключ первым).
assert _normalize_class("жильё комфорт+ класса") == "комфорт+"
def test_domrf_econom_synonyms(self) -> None:
# домрф канон эконома: стандарт / типовой (см. _OBJ_CLASS_PATTERNS).
assert _normalize_class("стандарт") == "стандарт"
assert _normalize_class("типовой") == "типовой"
def test_none_and_empty(self) -> None:
assert _normalize_class(None) is None
assert _normalize_class("") is None
assert _normalize_class(" ") is None
def test_unknown_returns_none(self) -> None:
assert _normalize_class("люксовый-апарт") is None
# ── _geo_proximity ────────────────────────────────────────────────────────────
class TestGeoProximity:
def test_at_parcel_is_one(self) -> None:
assert _geo_proximity(0.0, 1000.0) == 1.0
def test_at_radius_edge_is_zero(self) -> None:
assert _geo_proximity(1000.0, 1000.0) == 0.0
def test_midpoint(self) -> None:
assert _geo_proximity(500.0, 1000.0) == pytest.approx(0.5)
def test_beyond_radius_clamps_to_zero(self) -> None:
# За радиусом (не должно случаться — SQL фильтрует) → clamp 0, не negative.
assert _geo_proximity(1500.0, 1000.0) == 0.0
def test_none_distance_neutral(self) -> None:
assert _geo_proximity(None, 1000.0) == _NEUTRAL
def test_zero_radius_neutral(self) -> None:
assert _geo_proximity(100.0, 0.0) == _NEUTRAL
# ── _class_similarity ─────────────────────────────────────────────────────────
class TestClassSimilarity:
def test_same_class(self) -> None:
assert _class_similarity("комфорт", "комфорт") == 1.0
def test_adjacent_class(self) -> None:
# комфорт(1) ↔ комфорт+(2) = 1 шаг → 0.6.
assert _class_similarity("комфорт", "комфорт+") == pytest.approx(0.6)
assert _class_similarity("комфорт+", "комфорт") == pytest.approx(0.6)
def test_two_steps(self) -> None:
# эконом(0) ↔ комфорт+(2) = 2 шага → 0.3.
assert _class_similarity("эконом", "комфорт+") == pytest.approx(0.3)
def test_far_class(self) -> None:
# эконом(0) ↔ премиум(5) = 5 шагов → 0.1.
assert _class_similarity("эконом", "премиум") == pytest.approx(0.1)
# бизнес(3) ↔ эконом(0) = 3 шага → far 0.1.
assert _class_similarity("бизнес", "эконом") == pytest.approx(0.1)
def test_cross_language_match(self) -> None:
# comp латиницей, эталон кириллицей → один класс → 1.0.
assert _class_similarity("comfort", "Комфорт") == 1.0
def test_unknown_competitor_neutral(self) -> None:
assert _class_similarity(None, "комфорт") == _NEUTRAL
assert _class_similarity("неведомый", "комфорт") == _NEUTRAL
def test_unknown_reference_neutral(self) -> None:
assert _class_similarity("комфорт", None) == _NEUTRAL
# ── _price_similarity ─────────────────────────────────────────────────────────
class TestPriceSimilarity:
def test_on_median_is_one(self) -> None:
assert _price_similarity(100_000.0, 100_000.0) == 1.0
def test_fifty_pct_above(self) -> None:
# |150k-100k|/100k = 0.5 → 1-0.5 = 0.5.
assert _price_similarity(150_000.0, 100_000.0) == pytest.approx(0.5)
def test_fifty_pct_below_symmetric(self) -> None:
assert _price_similarity(50_000.0, 100_000.0) == pytest.approx(0.5)
def test_double_or_more_clamps_to_zero(self) -> None:
# +100% и больше → clamp 0 (не отрицательное).
assert _price_similarity(200_000.0, 100_000.0) == 0.0
assert _price_similarity(500_000.0, 100_000.0) == 0.0
def test_none_competitor_price_neutral(self) -> None:
assert _price_similarity(None, 100_000.0) == _NEUTRAL
def test_none_or_zero_median_neutral(self) -> None:
assert _price_similarity(100_000.0, None) == _NEUTRAL
assert _price_similarity(100_000.0, 0.0) == _NEUTRAL
# ── _stage_at_horizon (horizon-aware ключевой фактор) ──────────────────────────
class TestStageAtHorizon:
def test_projected_sold_out_is_low(self) -> None:
# 10 доступно, velocity 5/мес × 12 = 60 → остаток 0 → score 0.
assert _stage_at_horizon(10.0, 5.0, 12, 100.0) == 0.0
def test_active_competitor_high(self) -> None:
# 80 доступно, velocity 1/мес × 12 = 12 → остаток 68 из 100 → 0.68.
assert _stage_at_horizon(80.0, 1.0, 12, 100.0) == pytest.approx(0.68)
def test_partial_burn_down(self) -> None:
# 60 доступно, velocity 2/мес × 12 = 24 → остаток 36 из 100 → 0.36.
assert _stage_at_horizon(60.0, 2.0, 12, 100.0) == pytest.approx(0.36)
def test_zero_velocity_keeps_inventory(self) -> None:
# Нет продаж → остаток не уменьшается → доля доступного (50/100=0.5).
assert _stage_at_horizon(50.0, 0.0, 12, 100.0) == pytest.approx(0.5)
def test_thin_data_velocity_none_neutral(self) -> None:
assert _stage_at_horizon(50.0, None, 12, 100.0) == _NEUTRAL
def test_thin_data_available_none_neutral(self) -> None:
assert _stage_at_horizon(None, 5.0, 12, 100.0) == _NEUTRAL
def test_thin_data_total_none_neutral(self) -> None:
assert _stage_at_horizon(50.0, 5.0, 12, None) == _NEUTRAL
def test_zero_total_neutral_no_crash(self) -> None:
assert _stage_at_horizon(0.0, 5.0, 12, 0.0) == _NEUTRAL
def test_horizon_affects_projection(self) -> None:
# Тот же конкурент: короткий горизонт → больше остаток → выше score.
short = _stage_at_horizon(60.0, 2.0, 6, 100.0) # 60-12=48 → 0.48
long = _stage_at_horizon(60.0, 2.0, 24, 100.0) # 60-48=12 → 0.12
assert short > long
assert short == pytest.approx(0.48)
assert long == pytest.approx(0.12)
def test_remaining_clamped_non_negative(self) -> None:
# velocity огромная → projected_remaining clamp 0 (не отрицательный score).
assert _stage_at_horizon(5.0, 100.0, 12, 100.0) == 0.0
# ── _relevance_weight (взвешенная комбинация) ──────────────────────────────────
class TestRelevanceWeight:
def test_all_ones(self) -> None:
assert _relevance_weight(1.0, 1.0, 1.0, 1.0) == pytest.approx(1.0)
def test_all_zeros(self) -> None:
assert _relevance_weight(0.0, 0.0, 0.0, 0.0) == 0.0
def test_geo_only_equals_geo_weight(self) -> None:
assert _relevance_weight(1.0, 0.0, 0.0, 0.0) == pytest.approx(_W_GEO)
def test_stage_only_equals_stage_weight(self) -> None:
assert _relevance_weight(0.0, 0.0, 0.0, 1.0) == pytest.approx(_W_STAGE)
def test_weighted_mix(self) -> None:
# geo 0.30*1 + class 0.25*0.6 + price 0.20*0.5 + stage 0.25*0 = 0.55.
expected = _W_GEO * 1.0 + _W_CLASS * 0.6 + _W_PRICE * 0.5 + _W_STAGE * 0.0
assert _relevance_weight(1.0, 0.6, 0.5, 0.0) == pytest.approx(expected)
def test_result_clamped(self) -> None:
# Даже при «грязных» входах результат остаётся в [0,1].
assert 0.0 <= _relevance_weight(2.0, 2.0, 2.0, 2.0) <= 1.0
assert _relevance_weight(2.0, 2.0, 2.0, 2.0) == 1.0
def test_neutral_all_subscores_gives_neutral(self) -> None:
# Полностью тонкие данные (все 0.5) → итог 0.5 (не штраф, не завышение).
assert _relevance_weight(_NEUTRAL, _NEUTRAL, _NEUTRAL, _NEUTRAL) == pytest.approx(0.5)
# ── _dominant_class / _median ──────────────────────────────────────────────────
class TestDominantClass:
def test_most_common(self) -> None:
assert _dominant_class(["комфорт", "комфорт", "бизнес"]) == "комфорт"
def test_mixed_languages_counted_together(self) -> None:
# comfort + комфорт = один нормализованный класс → доминирует.
assert _dominant_class(["comfort", "комфорт", "бизнес"]) == "комфорт"
def test_ignores_unknown_and_none(self) -> None:
assert _dominant_class([None, "ерунда", "бизнес"]) == "бизнес"
def test_all_unknown_returns_none(self) -> None:
assert _dominant_class([None, "ерунда", ""]) is None
def test_empty_returns_none(self) -> None:
assert _dominant_class([]) is None
def test_tie_breaks_by_lowest_order_deterministic(self) -> None:
# Ничья эконом(0) vs бизнес(3) → меньший order-индекс (эконом).
assert _dominant_class(["эконом", "бизнес"]) == "эконом"
# Порядок входа не влияет — детерминированно.
assert _dominant_class(["бизнес", "эконом"]) == "эконом"
class TestMedian:
def test_odd(self) -> None:
assert _median([3.0, 1.0, 2.0]) == 2.0
def test_even(self) -> None:
assert _median([1.0, 2.0, 3.0, 4.0]) == pytest.approx(2.5)
def test_single(self) -> None:
assert _median([42.0]) == 42.0
def test_empty_none(self) -> None:
assert _median([]) is None
# ── class-order карта (документируется в отчёте) ───────────────────────────────
def test_class_order_is_monotonic_ladder() -> None:
"""эконом < комфорт < комфорт+ < бизнес < бизнес+ < премиум."""
assert _CLASS_ORDER["эконом"] < _CLASS_ORDER["комфорт"] < _CLASS_ORDER["комфорт+"]
assert _CLASS_ORDER["комфорт+"] < _CLASS_ORDER["бизнес"] < _CLASS_ORDER["бизнес+"]
assert _CLASS_ORDER["бизнес+"] < _CLASS_ORDER["премиум"]