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:
parent
b9259cd8ed
commit
0d11f426b2
2 changed files with 67 additions and 0 deletions
|
|
@ -48,6 +48,10 @@ logger = logging.getLogger(__name__)
|
||||||
LAYOUT_CONFIDENCE_HIGH_PCT = 50.0
|
LAYOUT_CONFIDENCE_HIGH_PCT = 50.0
|
||||||
LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.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).
|
# Параметры time_window: (PostgreSQL interval string, months divisor для velocity_per_month).
|
||||||
# Используются в _INLINE_VELOCITY_SQL — реальный фильтр по report_month.
|
# Используются в _INLINE_VELOCITY_SQL — реальный фильтр по report_month.
|
||||||
# Fix SF-01: убраны _VELOCITY_DIVISORS, которые делили MV (24 мес) без изменения данных.
|
# Fix SF-01: убраны _VELOCITY_DIVISORS, которые делили MV (24 мес) без изменения данных.
|
||||||
|
|
@ -219,6 +223,57 @@ def _normalize_pct(buckets: dict[str, float]) -> dict[str, int]:
|
||||||
return floors
|
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())
|
total_deals = sum(rb_deals.values())
|
||||||
pct_map = _normalize_pct(rb_deals)
|
pct_map = _normalize_pct(rb_deals)
|
||||||
|
pct_map = _cap_and_redistribute(pct_map)
|
||||||
|
|
||||||
mix: list[LayoutTzMixRow] = []
|
mix: list[LayoutTzMixRow] = []
|
||||||
for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]):
|
for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]):
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,9 @@ function RecommendationCard({
|
||||||
}: {
|
}: {
|
||||||
rec: BestLayoutsResponse["recommendation_for_tz"];
|
rec: BestLayoutsResponse["recommendation_for_tz"];
|
||||||
}) {
|
}) {
|
||||||
|
const maxPct =
|
||||||
|
rec.mix.length > 0 ? Math.max(...rec.mix.map((r) => r.pct)) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -365,6 +368,14 @@ function RecommendationCard({
|
||||||
gap: 16,
|
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 */}
|
{/* Rationale text — plain text only, no dangerouslySetInnerHTML */}
|
||||||
<p
|
<p
|
||||||
style={{ fontSize: 13, color: "#374151", margin: 0, lineHeight: 1.6 }}
|
style={{ fontSize: 13, color: "#374151", margin: 0, lineHeight: 1.6 }}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue