From 2450159fe9a481fe28995bb0fd7bf79a803295bb Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 00:45:28 +0500 Subject: [PATCH] feat(site-finder): per-competitor relevance_weight (#949 PR B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/schemas/parcel.py | 10 + .../app/services/site_finder/competitors.py | 299 ++++++++++++++++- .../site_finder/test_competitor_relevance.py | 306 ++++++++++++++++++ 3 files changed, 600 insertions(+), 15 deletions(-) create mode 100644 backend/tests/services/site_finder/test_competitor_relevance.py diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 1e6d3c1a..d7637511 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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): diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index c59889d4..53cd55d7 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -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) diff --git a/backend/tests/services/site_finder/test_competitor_relevance.py b/backend/tests/services/site_finder/test_competitor_relevance.py new file mode 100644 index 00000000..c431fa8b --- /dev/null +++ b/backend/tests/services/site_finder/test_competitor_relevance.py @@ -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 расстояния (parcel→1, край радиуса→0). + • _class_similarity — карта по шагам в _CLASS_ORDER (same/adjacent/2/far), + нейтраль 0.5 при неизвестном классе. + • _price_similarity — близость к локальной медиане; нейтраль при None. + • _stage_at_horizon — horizon-aware проекция распродажи (sold-out→low, + активный→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["премиум"] -- 2.45.3