fix(sf-09): MAX_BUCKET_SHARE 35% cap + frontend warning banner

_build_recommendation зеркалило рынок через чистый Hamilton apportionment.
Добавлен _cap_and_redistribute: bucket >35% clamp к 35%, surplus
пропорционально перераспределяется в остальные bucket'ы (Hamilton финальный
pass гарантирует sum=100). Pathological case (все bucket'ы >35%) — warning
в лог, cap пропускается. Frontend: warning banner при maxPct>60 в
RecommendationCard (Tailwind amber utilities, без inline hex).
This commit is contained in:
lekss361 2026-05-17 14:49:56 +03:00
parent b9259cd8ed
commit 0d11f426b2
2 changed files with 67 additions and 0 deletions

View file

@ -48,6 +48,10 @@ logger = logging.getLogger(__name__)
LAYOUT_CONFIDENCE_HIGH_PCT = 50.0
LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.0
# Fix SF-09: cap доминирующего bucket чтобы рекомендация не зеркалила перекос рынка.
# Избыток перераспределяется пропорционально остальным bucket'ам.
MAX_BUCKET_SHARE_PCT = 35
# Параметры time_window: (PostgreSQL interval string, months divisor для velocity_per_month).
# Используются в _INLINE_VELOCITY_SQL — реальный фильтр по report_month.
# Fix SF-01: убраны _VELOCITY_DIVISORS, которые делили MV (24 мес) без изменения данных.
@ -219,6 +223,57 @@ def _normalize_pct(buckets: dict[str, float]) -> dict[str, int]:
return floors
def _cap_and_redistribute(pct_map: dict[str, int]) -> dict[str, int]:
"""Fix SF-09: clamp каждый bucket к MAX_BUCKET_SHARE_PCT, перераспределить излишек.
Алгоритм:
1. Найти clamped (> cap) и free bucket'ы.
2. Если ВСЕ bucket'ы clamped — pathological case (редко, напр. 2 bucket'а по 50%):
вернуть как есть с warning (сумма уже = 100).
3. Surplus пропорционально добавить к free bucket'ам (взвешено по их original share).
4. Финальный Hamilton pass чтобы сумма осталась ровно 100.
"""
if not pct_map:
return pct_map
cap = MAX_BUCKET_SHARE_PCT
clamped = {k: v for k, v in pct_map.items() if v > cap}
if not clamped:
return pct_map # нет доминирующих — ничего не делаем
free = {k: v for k, v in pct_map.items() if v <= cap}
if not free:
# Все bucket'ы выше cap — pathological (напр. 2 bucket'а по 50%).
# Нельзя перераспределить — возвращаем как есть.
logger.warning(
"MAX_BUCKET_SHARE cap: все %d bucket'ов выше %d%% — пропускаем redistribute",
len(pct_map),
cap,
)
return pct_map
surplus = sum(v - cap for v in clamped.values())
# Новые значения для clamped bucket'ов
result: dict[str, int] = {k: cap for k in clamped}
# Пропорциональный redistribute в free bucket'ы
free_total = sum(free.values())
# Дробные проценты после redistribution
free_raw: dict[str, float] = {}
for k, v in free.items():
free_raw[k] = v + (v / free_total * surplus if free_total > 0 else surplus / len(free))
# Hamilton pass чтобы сумма clamped + free = 100
target_sum = 100 - sum(result.values()) # сколько должно остаться у free
free_floors = {k: int(v) for k, v in free_raw.items()}
free_remainder = target_sum - sum(free_floors.values())
free_fracs = sorted(free_raw.keys(), key=lambda k: -(free_raw[k] - free_floors[k]))
for k in free_fracs[: max(0, free_remainder)]:
free_floors[k] += 1
result.update(free_floors)
return result
# ── Главная функция ───────────────────────────────────────────────────────────
@ -552,6 +607,7 @@ def _build_recommendation(
total_deals = sum(rb_deals.values())
pct_map = _normalize_pct(rb_deals)
pct_map = _cap_and_redistribute(pct_map)
mix: list[LayoutTzMixRow] = []
for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]):

View file

@ -345,6 +345,9 @@ function RecommendationCard({
}: {
rec: BestLayoutsResponse["recommendation_for_tz"];
}) {
const maxPct =
rec.mix.length > 0 ? Math.max(...rec.mix.map((r) => r.pct)) : 0;
return (
<div
style={{
@ -365,6 +368,14 @@ function RecommendationCard({
gap: 16,
}}
>
{/* SF-09: предупреждение о сильном перекосе unit-mix */}
{maxPct > 60 && (
<div className="bg-amber-50 border border-amber-200 text-amber-800 rounded p-3 text-sm">
Рекомендация имеет сильный перекос ({maxPct}% в одном формате)
проверьте competitive density и district pipeline.
</div>
)}
{/* Rationale text — plain text only, no dangerouslySetInnerHTML */}
<p
style={{ fontSize: 13, color: "#374151", margin: 0, lineHeight: 1.6 }}