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_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]):
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue