gendesign/frontend/src/components/site-finder/analysis/forecast-helpers.ts
Light1YT 8fed54971f
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 57s
CI / openapi-codegen-check (pull_request) Successful in 1m51s
fix(forecast): §6 honesty layer — pegged-signal plaque + non-residential caveat + segment/pipeline note (#1953)
Из live-walkthrough Anton'а (66:41:0702017:131): дефицит −1.00 «затоварка» на всех
горизонтах — данные КОРРЕКТНЫ (Кировский Комфорт: 12156 доступных ÷ ~100/мес = ~155 мес,
genuine затоварка при ставке 14.5%), но презентация ложно-точная/сбивающая.

B1: deficitPegState helper — детект насыщения (|deficit_index|≥0.99 одного знака на
    ВСЕХ горизонтах) → честная плашка «сигнал на пределе шкалы, −1.00 не различает
    горизонты, оценка грубая» над 6.1/6.2 + маркер «НА ПРЕДЕЛЕ» вместо ложно-точного ×4.
B2: поднял product_tz.gate_caveat в заметную плашку ВВЕРХ §6 (раньше не рендерился
    нигде) — «участок не под жильё → прогноз справочный».
B3: микрокопия почему future=0 (посегментный баланс vs класс-агностичный pipeline §4.3).

Edit-only, контракт не менялся. +8 unit-тестов на pegState.
2026-06-29 16:23:29 +05:00

351 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Shared helpers for Section 6 (forecast) blocks — deficit semantics, confidence
* mapping, RU number formatting. Kept framework-agnostic (no JSX) so the three
* forecast blocks share one source of truth.
*/
import type { BadgeVariant } from "@/components/ui/Badge";
import type {
ConfidenceLevel,
DemandSupplyForecast,
FutureSupply,
ProductMixEntry,
} from "@/types/forecast";
/** Below this |deficit_index| we treat the market as balanced (neutral). */
export const DEFICIT_BALANCE_EPS = 0.05;
/**
* #1958/#1959 honesty-guard — at/above this |deficit_index| the signal is
* SATURATED («упёрся» в край шкалы). The backend clamps deficit_index to [1, 1]
* and saturates the index at balance_ratio=0.5; when supply massively dwarfs
* demand (ratio ≪ 0.5 на всех горизонтах) every horizon clamps to exactly 1.00
* (зеркально +1.00 для острого дефицита). Showing the SAME «1.00» ×4 reads as
* broken / falsely precise — so we mark such values «на пределе» and раскрываем
* честную плашку. Mirrors `ForecastChart.DEGENERATE_CLAMP_EPS` (0.02 →
* |value| ≥ 0.98) but tightened to 0.99 per the §6 brief (а у графика свой порог).
*/
export const DEFICIT_PEG_THRESHOLD = 0.99;
/** Saturated (clamp-floor) signal? `|deficit_index| ≥ DEFICIT_PEG_THRESHOLD`. */
export function isDeficitPegged(deficitIndex: number): boolean {
return Math.abs(deficitIndex) >= DEFICIT_PEG_THRESHOLD;
}
/**
* Pegged-signal state across a set of horizon rows (HONESTY-GUARD, #1958/#1959).
* The signal is «на пределе шкалы» only when EVERY present deficit_index is
* saturated (|value| ≥ 0.99) AND all саме знака — then «1.00 ×4» не различает
* горизонты и плашка честнее точного числа. A single non-pegged / opposite-sign
* row means there IS differentiation → not pegged (plain table tells the story).
*
* Returns `{ pegged, sign }`:
* pegged — render the honesty plaque + mark values «на пределе»;
* sign — 1 затоварка (supply ≫ demand) / +1 острый дефицит, или null when
* не pegged. Drives which RU plaque text to show (zeroкально).
* PURE (no JSX) — single source of truth shared by 6.1 (table) and 6.2 (scenarios).
*/
export interface DeficitPegState {
pegged: boolean;
sign: -1 | 1 | null;
}
export function deficitPegState(
forecasts: Pick<DemandSupplyForecast, "deficit_index">[],
): DeficitPegState {
const present = forecasts
.map((f) => f.deficit_index)
.filter((v): v is number => v != null);
if (present.length === 0) return { pegged: false, sign: null };
const allPegged = present.every(isDeficitPegged);
if (!allPegged) return { pegged: false, sign: null };
const allSameSign =
present.every((v) => v > 0) || present.every((v) => v < 0);
if (!allSameSign) return { pegged: false, sign: null };
return { pegged: true, sign: present[0] > 0 ? 1 : -1 };
}
/**
* Human RU plaque text for a pegged deficit signal (#1958/#1959). Финдиректору
* объясняем простым языком, что точное «1.00 ×4» — артефакт упора в край шкалы
* (рынок перенасыщен), а не различие горизонтов. Зеркально для +1.0 — острый
* дефицит. Источник правды для 6.1 и 6.2 (не дублировать строку в TSX).
*/
export function deficitPegPlaque(sign: -1 | 1): string {
if (sign < 0) {
return (
"Сигнал на пределе шкалы: предложение во много раз превышает спрос на всех " +
"горизонтах — сильная затоварка. Точное 1.00 не различает горизонты (рынок " +
"перенасыщен), оценка грубая."
);
}
return (
"Сигнал на пределе шкалы: спрос во много раз превышает предложение на всех " +
"горизонтах — острый дефицит. Точное +1.00 не различает горизонты (рынок " +
"сильно недонасыщен), оценка грубая."
);
}
/**
* Deficit semantics (HARD): >0 недонасыщенность (повод строить, success);
* <0 затоварка (warn/danger); ≈0 баланс (neutral).
*/
export function deficitVariant(deficitIndex: number): BadgeVariant {
if (deficitIndex > DEFICIT_BALANCE_EPS) return "success";
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "danger";
return "neutral";
}
export function deficitWord(deficitIndex: number): string {
if (deficitIndex > DEFICIT_BALANCE_EPS) return "недонасыщенность";
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "затоварка";
return "баланс";
}
/**
* Сигнал-вердикт по deficit_index для таблицы форматов (#1745): «строить»
* (недонасыщенность) / «баланс» / «избегать» (затоварка). Зеркало backend
* `report_assembler._build_signal` — фронт-fallback, когда mix-ячейка не несёт
* готового `signal` (старый прогон). Семантика та же, что у `deficitWord`.
*/
export function deficitSignalWord(deficitIndex: number): string {
if (deficitIndex > DEFICIT_BALANCE_EPS) return "строить";
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "избегать";
return "баланс";
}
// ── Class-level deficit aggregation (#1742 — объяснение выбора класса) ──────────
/** Агрегат дефицита одного класса: среднее deficit_index по его форматам. */
export interface ClassDeficit {
obj_class: string;
/** Среднее deficit_index по форматам класса (null — у всех форматов нет индекса). */
meanDeficitIndex: number | null;
/** Сколько форматов класса несут не-null deficit_index (для honest-агрегата). */
nWithIndex: number;
}
/**
* Агрегировать `mix` (квартирография — ячейки формат×класс) в per-класс дефицит:
* среднее deficit_index по форматам каждого класса. Зеркало backend
* `recommendation._recommend_class` (среднее по room-bucket'ам класса). Это даёт
* мини-таблицу 3 классов (#1742) из уже-доступного `mix` без backend-расширения —
* `mix` несёт все 15 ячеек (5 форматов × 3 класса) с per-ячейкой deficit_index.
*
* Сортировка DESC по meanDeficitIndex (сильнейший дефицит сверху, как в ранкинге);
* классы без obj_class игнорируются (нечего агрегировать — НЕ фабрикуем класс).
* Класс, у которого ни у одной ячейки нет индекса → meanDeficitIndex=null (фронт
* рисует «тонкие данные», НЕ 0).
*/
export function aggregateClassDeficits(mix: ProductMixEntry[]): ClassDeficit[] {
const byClass = new Map<string, number[]>();
for (const m of mix) {
if (m.obj_class == null) continue;
const list = byClass.get(m.obj_class) ?? [];
if (m.deficit_index != null) list.push(m.deficit_index);
byClass.set(m.obj_class, list);
}
const out: ClassDeficit[] = [];
for (const [obj_class, values] of byClass) {
const meanDeficitIndex =
values.length > 0
? values.reduce((a, b) => a + b, 0) / values.length
: null;
out.push({ obj_class, meanDeficitIndex, nWithIndex: values.length });
}
// DESC по среднему дефициту; null-индексы — в конец (стабильно). tie-break — класс ASC.
out.sort((a, b) => {
const av = a.meanDeficitIndex;
const bv = b.meanDeficitIndex;
if (av == null && bv == null) return a.obj_class.localeCompare(b.obj_class);
if (av == null) return 1;
if (bv == null) return -1;
if (bv !== av) return bv - av;
return a.obj_class.localeCompare(b.obj_class);
});
return out;
}
/**
* Bar width (%) for a signed deficit_index (∈ 1..1) on a 0..100 scale. Uses the
* magnitude so both недонасыщенность (+) and затоварка () read as a filled bar
* (colour carries the sign via `deficitVariant`). Clamped to [0, 100].
*/
export function deficitBarWidthPct(deficitIndex: number): number {
const pct = Math.abs(deficitIndex) * 100;
return Math.max(0, Math.min(100, pct));
}
// ── Confidence ────────────────────────────────────────────────────────────────
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
high: "высокая",
medium: "средняя",
low: "низкая",
};
export function confidenceVariant(level: ConfidenceLevel): BadgeVariant {
if (level === "high") return "success";
if (level === "medium") return "warning";
return "danger";
}
// ── Scoring (§13.6 — product scores #985 + special indices #986) ───────────────
/**
* Product-score semantics (HARD): every §14.2 score is ∈ [0,1] with «выше =
* лучше для девелопера» (risk scores supply_risk / future_competition /
* mortgage_sensitivity are pre-inverted at the source — so score>0.5 =
* favourable). The backend anchors quality on 0.5 = баланс/midpoint
* (`product_scoring.py:127` `_MARKET_FIT_MIDPOINT = 0.5`; inverted-risk reasons
* flip favourable/unfavourable at risk<0.5 → score>0.5), so the gradient is
* anchored on 0.5 too: a narrow ±0.05 neutral band straddles the balance point —
* ≥0.55 success / 0.450.55 warning / <0.45 danger. This is NOT the deficit
* mapping (signed [1,1]) — do not reuse deficitVariant here.
*/
export const SCORE_GOOD_THRESHOLD = 0.55;
export const SCORE_WEAK_THRESHOLD = 0.45;
export function scoreVariant(value: number): BadgeVariant {
if (value >= SCORE_GOOD_THRESHOLD) return "success";
if (value >= SCORE_WEAK_THRESHOLD) return "warning";
return "danger";
}
/**
* Bar width (%) for a 0..1 score on a 0..100 scale (direct, no magnitude trick —
* the score is already unsigned 0..1). Clamped to [0, 100].
*/
export function scoreBarWidthPct(value: number): number {
return Math.max(0, Math.min(100, value * 100));
}
/**
* RU labels for the 10 §14.2 product-score keys (#985). The MD/PDF exporters
* render the raw English key — these are the established in-app RU names, kept
* here so the block and any future reuse share one source of truth. Unknown
* keys fall back to the raw key (graceful).
*/
export const PRODUCT_SCORE_RU: Record<string, string> = {
market_fit: "Соответствие рынку",
demand: "Спрос",
// #1963: «Риск …» сбивал (скор инвертирован, выше=лучше) → «Запас по предложению».
supply_risk: "Запас по предложению",
future_competition: "Будущая конкуренция",
price_feasibility: "Доступность цены",
infra_fit: "Инфраструктура",
mortgage_sensitivity: "Чувствительность к ставке",
differentiation: "Дифференциация",
commercial: "Коммерция",
confidence: "Надёжность данных",
};
/**
* RU labels for the 6 §25 special-index keys (#986). The per-entry `label` field
* is a value-descriptor («6 мес» / «Комфорт»), not a metric name, so it is shown
* as supplementary context — these are the metric names. Unknown keys fall back
* to the raw key (graceful).
*/
export const SPECIAL_INDEX_RU: Record<string, string> = {
launch_window: "Окно запуска",
product_void: "Белые пятна продукта",
cannibalization: "Каннибализация",
competitor_strength: "Сила конкурентов",
artificial_demand: "Искусственный спрос",
cost_of_error: "Цена ошибки",
};
/**
* #1963 — однострочное «что это + куда лучше» для 6 спец-индексов. Раньше индексы
* показывались только сырым именем + числом без объяснения, что значит «выше» —
* а направление у них РАЗНОЕ (часть растёт к лучшему, часть к худшему). Здесь
* проговариваем смысл и желаемую сторону. Источник правды до появления поля от
* бэкенда — этот map (graceful: незнакомый ключ → пусто, строка просто без подписи).
*/
export const SPECIAL_INDEX_DESC: Record<string, string> = {
product_void:
"Доля незакрытых ниш (форматов с дефицитом) — выше лучше: больше места для нового продукта.",
cost_of_error:
"Цена ошибки выхода = риск затоварки × средний чек лота — ниже лучше: дешевле ошибиться.",
launch_window:
"Лучший момент для старта по горизонтам прогноза — раньше окно, тем лучше.",
cannibalization:
"Насколько проект отъедает спрос у соседних ЖК того же класса — ниже лучше.",
artificial_demand:
"Доля спроса, держащегося на льготной ипотеке (уязвима к смене ставки) — ниже лучше.",
competitor_strength:
"Сила топ-конкурентов рядом — ниже лучше: слабее конкуренты, проще выйти.",
};
/**
* #1963 — вынести §-ссылки («§10.4», «§9.6», «§16») из основного reason в отдельную
* деталь/тултип. Возвращает {clean, refs}: `clean` — текст без §-маркеров и пустых
* скобок, `refs` — извлечённые ссылки (через запятую) для tooltip. PURE.
*/
export function stripSectionRefs(reason: string): {
clean: string;
refs: string | null;
} {
// Match «§9.6» / «§10.4» / «§13.6a» but NOT the sentence-final dot after it.
// Trailing letter only when the ref ends at a separator/EOL (не «съедает» букву
// следующего слова, если § приклеена без пробела).
const SECTION_RE = /§\s*\d+(?:\.\d+)*(?:[a-zа-я](?=$|[\s.,;)]))?/gi;
const refs = reason.match(SECTION_RE) ?? [];
let clean = reason.replace(SECTION_RE, "");
// Подчистить осиротевшие скобки/запятые/двойные пробелы после выреза ссылки.
clean = clean
.replace(/\(\s*[,;]?\s*\)/g, "")
.replace(/,\s*\)/g, ")")
.replace(/\(\s+/g, "(")
.replace(/\s+\)/g, ")")
.replace(/\s{2,}/g, " ")
.replace(/\s+([.,;)])/g, "$1")
.trim();
return { clean, refs: refs.length > 0 ? refs.join(", ") : null };
}
// ── Future supply (§9.3 — 6.6 evidence panel) ──────────────────────────────────
/**
* Does the §9.3 future-supply payload carry a GENUINELY-COMPUTED pressure signal
* worth rendering the supply panel for? Single source of truth — gates both the
* 6.6 block's panel and Section6Forecast's 6.6 sub-block (so they can't drift).
*
* Gates on the `_round_or_none` metrics ONLY (index / months_of_pressure /
* future_units_by_horizon / monthly_absorption_units) — DELIBERATELY excludes
* `open_units` / `hidden_units`. Those are integer stocks that are 0-NOT-NULL at
* the source: when `supply_layers` is empty (e.g. before the Monday 06:00 worker
* loads it) the SQL returns 0 rows → open/hidden = 0 while every computed metric
* is null. Triggering on open/hidden would render «Открытый сток: 0 ед. / Скрытый
* запас: 0 ед.», falsely reading as "zero future supply = safe" when the truth is
* "supply data not loaded yet" (null ≠ 0). Gating on the computed metrics keeps
* the panel OFF until a real pressure index exists; open/hidden are still DISPLAYED
* once it does (a genuine 0 then is honest).
*/
export function hasSupplySignal(fs: FutureSupply | null): boolean {
if (fs == null) return false;
const b = fs.breakdown;
return (
fs.index != null ||
b.months_of_pressure != null ||
b.future_units_by_horizon != null ||
b.monthly_absorption_units != null
);
}
// ── Number formatting ─────────────────────────────────────────────────────────
/**
* Fixed-decimal RU number (тонкая неразрывная группировка тысяч). Нормализует
* ведущий знак минуса в Unicode «−» (U+2212) per ui-microcopy (≥ ≤ ± Unicode).
* `.replace` бьёт только первое вхождение = ведущий знак (RU-группировка — NBSP,
* не дефис), так что числовая часть не затрагивается.
*/
export function fmtNum(value: number, digits = 1): string {
return value
.toLocaleString("ru", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
.replace("-", "");
}