fix(forecast): §6 honesty layer — pegged deficit, non-residential caveat, segment/pipeline note (#1953) #2096

Merged
bot-backend merged 1 commit from fix/forecast-deficit-honesty-batchB-1953 into main 2026-06-29 11:26:36 +00:00
5 changed files with 331 additions and 7 deletions

View file

@ -19,15 +19,25 @@
import { Badge } from "@/components/ui/Badge";
import type { DemandSupplyForecast } from "@/types/forecast";
import {
deficitPegPlaque,
deficitVariant,
deficitWord,
fmtNum,
isDeficitPegged,
type DeficitPegState,
} from "./forecast-helpers";
interface Props {
forecasts: DemandSupplyForecast[];
/** Horizon (мес) to emphasise as the current target — highlights its row. */
targetHorizon?: number;
/**
* B1 (#1958/#1959) pegged-signal state (deficit_index уперся в край шкалы на
* всех горизонтах). Считается в Section6Forecast по общему ряду и прокидывается
* сюда + в 6.2, чтобы плашка «на пределе» не разъезжалась. Когда не задан
* по умолчанию «не pegged» (плашка скрыта).
*/
peg?: DeficitPegState;
}
const TH_STYLE: React.CSSProperties = {
@ -53,7 +63,11 @@ const TD_STYLE: React.CSSProperties = {
const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" };
export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
export function ForecastHorizonsBlock({
forecasts,
targetHorizon,
peg,
}: Props) {
if (forecasts.length === 0) {
return (
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
@ -68,6 +82,10 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{/* B1 честная плашка, когда индекс упёрся в край шкалы на всех горизонтах:
точное «1.00 ×4» не различает горизонты, оценка грубая. */}
{peg?.pegged && peg.sign != null && <DeficitPegPlaque sign={peg.sign} />}
<div style={{ overflowX: "auto" }}>
<table
style={{
@ -103,7 +121,11 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
<td
style={
isTarget
? { ...TD_STYLE, fontWeight: 600, color: "var(--accent)" }
? {
...TD_STYLE,
fontWeight: 600,
color: "var(--accent)",
}
: TD_STYLE
}
>
@ -137,6 +159,22 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
>
{deficitWord(di)}
</span>
{/* B1 значение на краю шкалы: помечаем «на пределе», чтобы
одинаковые 1.00 ×4 не читались как ложно-точные. */}
{peg?.pegged && isDeficitPegged(di) && (
<span
style={{
marginLeft: 8,
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-tertiary)",
}}
>
на пределе
</span>
)}
</td>
<td style={NUM_TD}>
{Math.round(f.projected_demand_units).toLocaleString("ru")}
@ -165,6 +203,22 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
уверенность средней.
</p>
{/* B3 почему «Прогноз предложения» здесь может быть 0, хотя в §4.3 виден
крупный pipeline: баланс §6 посегментный (класс/район), а §4.3 класс-
агностичный будущий ввод. Снимает кажущееся противоречие 4.3 6. */}
<p
style={{
margin: 0,
fontSize: 12,
lineHeight: "16px",
color: "var(--fg-tertiary)",
}}
>
Баланс считается по сегменту (класс/район); класс-агностичный будущий
pipeline из раздела 4.3 в посегментный баланс не входит поэтому
будущее предложение здесь может быть 0.
</p>
{/* Rate-sensitivity phrase shared across horizons in current data; show
the distinct phrases once each to avoid noisy repetition. */}
<RateSensitivityNotes forecasts={rows} />
@ -172,6 +226,32 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
);
}
/**
* B1 (#1958/#1959) honest plaque shown above 6.1 / inside 6.2 when the deficit
* signal is saturated at the scale edge on every horizon. Exported so 6.2
* (ScenariosBlock) renders the identical plaque (one visual source of truth). The
* RU text comes from `deficitPegPlaque` (helper) never hardcoded per block.
* Warn pattern (--warn-soft / role="note") как у остальных caveat-плашек §6.
*/
export function DeficitPegPlaque({ sign }: { sign: -1 | 1 }) {
return (
<div
role="note"
style={{
padding: "10px 14px",
borderRadius: 8,
background: "var(--warn-soft, #FEF3C7)",
border: "1px solid var(--warn, #9A6700)",
fontSize: 12,
lineHeight: "16px",
color: "var(--warn, #9A6700)",
}}
>
{deficitPegPlaque(sign)}
</div>
);
}
function RateSensitivityNotes({
forecasts,
}: {

View file

@ -21,12 +21,21 @@ import {
deficitVariant,
deficitWord,
fmtNum,
type DeficitPegState,
} from "./forecast-helpers";
import { DeficitPegPlaque } from "./ForecastHorizonsBlock";
interface Props {
scenarios: ReportScenarios;
scenariosSummary: ScenariosSummary | null;
targetHorizon: number;
/**
* B1 (#1958/#1959) pegged-signal state (deficit_index уперся в край шкалы на
* всех горизонтах). Тот же объект, что у 6.1 считается один раз в
* Section6Forecast. Когда pegged, над карточками показываем честную плашку
* «на пределе», т.к. 1.00 одинаков во всех сценариях.
*/
peg?: DeficitPegState;
}
// Display order: conservative → base → aggressive (worst-to-best rate path).
@ -80,7 +89,12 @@ export function ScenariosBlock({
scenarios,
scenariosSummary,
targetHorizon,
peg,
}: Props) {
const pegPlaque =
peg?.pegged && peg.sign != null ? (
<DeficitPegPlaque sign={peg.sign} />
) : null;
const present = SCENARIO_ORDER.filter(
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
);
@ -109,6 +123,7 @@ export function ScenariosBlock({
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{pegPlaque}
<div
style={{
borderRadius: 12,
@ -157,6 +172,7 @@ export function ScenariosBlock({
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{pegPlaque}
<div
style={{
display: "grid",

View file

@ -21,7 +21,7 @@
* advisory disclaimer (footer)
*/
import { LineChart } from "lucide-react";
import { AlertTriangle, LineChart } from "lucide-react";
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { KpiCard } from "@/components/site-finder/KpiCard";
@ -40,6 +40,7 @@ import { ForecastMetaLine } from "./ForecastMetaLine";
import { ForecastExportButtons } from "./ForecastExportButtons";
import {
CONFIDENCE_RU,
deficitPegState,
deficitVariant,
fmtNum,
hasSupplySignal,
@ -256,6 +257,17 @@ function ForecastReady({
hasScoring ||
hasFutureSupplyDetail;
// B1 (#1958/#1959) — pegged-signal honesty-guard. deficit_index упирается в край
// шкалы (|value| ≥ 0.99) на ВСЕХ горизонтах того же знака → «1.00 ×4» не
// различает горизонты. Считаем ОДИН раз тут (источник — общий ряд горизонтов) и
// прокидываем в 6.1 (таблица) и 6.2 (сценарии), чтобы плашки не разъезжались.
const peg = deficitPegState(fm.forecasts_by_horizon);
// B2 — gate-caveat участка (см. блокеры: «Нельзя строить МКД»). Бэкенд проставляет
// product_tz.gate_caveat, когда жильё под запретом → весь §6-прогноз справочный.
// Поднимаем его на уровень ВСЕЙ секции (был бы закопан в 6.4) — заметная плашка.
const gateCaveat = pt?.gate_caveat?.trim() || null;
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
const subtitle = fm.summary ?? undefined;
@ -271,6 +283,10 @@ function ForecastReady({
gap: 24,
}}
>
{/* B2 участок не под жильё: прогноз спроса/предложения справочный.
Заметная плашка В ВЕРХУ всего §6 (выше KPI), а не закопанная в 6.4. */}
{gateCaveat && <NonResidentialCaveat caveat={gateCaveat} />}
{/* Export / share forecast report (EPIC #959) */}
<ForecastExportButtons cad={cad} />
@ -330,9 +346,7 @@ function ForecastReady({
(exec.overall_confidence ?? kn.confidence) != null
? CONFIDENCE_RU[
(exec.overall_confidence ?? kn.confidence) as
| "high"
| "medium"
| "low"
"high" | "medium" | "low"
]
: "—"
}
@ -370,6 +384,7 @@ function ForecastReady({
<ForecastHorizonsBlock
forecasts={fm.forecasts_by_horizon}
targetHorizon={targetHorizon}
peg={peg}
/>
</SubBlock>
)}
@ -381,6 +396,7 @@ function ForecastReady({
scenarios={report.scenarios}
scenariosSummary={fm.scenarios_summary}
targetHorizon={targetHorizon}
peg={peg}
/>
</SubBlock>
)}
@ -457,6 +473,60 @@ function ForecastReady({
);
}
// ── NonResidentialCaveat — участок не под жильё (B2) ──────────────────────────
//
// Заметная warn-плашка В ВЕРХУ §6: бэкенд проставил product_tz.gate_caveat, когда
// gate=«Нельзя строить МКД». Весь прогноз спроса/предложения тогда — справочный
// (считается по сегменту рынка, но строить жильё на участке нельзя). Паттерн
// --warn-soft / role="note" как у СЗЗ-баннера / VelocityBlock-caveat; иконка
// Lucide (НЕ emoji), токены с fallback. `caveat` — готовый RU-текст от бэкенда;
// под ним — наша микрокопия-следствие «прогноз носит справочный характер».
function NonResidentialCaveat({ caveat }: { caveat: string }) {
return (
<div
role="note"
style={{
display: "flex",
gap: 10,
alignItems: "flex-start",
padding: "12px 16px",
borderRadius: 12,
background: "var(--warn-soft, #FEF3C7)",
border: "1px solid var(--warn, #9A6700)",
}}
>
<AlertTriangle
size={18}
aria-hidden
style={{ flexShrink: 0, marginTop: 1, color: "var(--warn, #9A6700)" }}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
lineHeight: 1.5,
color: "var(--warn, #9A6700)",
}}
>
Участок не предназначен под жильё (см. блокеры) прогноз спроса и
предложения носит справочный характер.
</span>
<span
style={{
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-secondary)",
}}
>
{caveat}
</span>
</div>
</div>
);
}
// ── DeficitLegend — что значит шкала 1…+1 + связь с MOI (#1963) ─────────────
//
// Плоская строка-легенда под KPI: финдиректор видит «0.42» / «116.6 мес» без
@ -533,7 +603,9 @@ function DeficitLegend() {
</div>
))}
</div>
<span style={{ fontSize: 12, lineHeight: 1.5, color: "var(--fg-tertiary)" }}>
<span
style={{ fontSize: 12, lineHeight: 1.5, color: "var(--fg-tertiary)" }}
>
«Мес. предложения» (MOI) за сколько месяцев район распродаст текущее
предложение при нынешнем темпе продаж: чем больше месяцев, тем сильнее
затоварка и ниже индекс.

View file

@ -0,0 +1,84 @@
/**
* Tests for `deficitPegState` / `isDeficitPegged` B1 honesty-guard (#1958/#1959).
*
* The §22 deficit_index is clamped to [1, 1] and saturates at balance_ratio=0.5;
* when supply massively dwarfs demand (ratio 0.5 на всех горизонтах) every
* horizon clamps to exactly 1.00 (зеркально +1.00 для острого дефицита). Showing
* «1.00 ×4» reads as broken / falsely precise, so Section6Forecast detects the
* saturated state once and renders a honest «на пределе» plaque over 6.1 / 6.2.
*
* The state is pegged only when EVERY present row is saturated AND same-signed
* a single non-pegged / opposite-sign row means there IS differentiation.
*/
import { describe, expect, it } from "vitest";
import { deficitPegState, isDeficitPegged } from "../forecast-helpers";
function rows(...values: (number | null)[]) {
return values.map(
(deficit_index) =>
({ deficit_index }) as {
deficit_index: number;
},
);
}
describe("isDeficitPegged — B1 saturation threshold (#1958/#1959)", () => {
it("is pegged at ±1.00 and within eps of the edge", () => {
expect(isDeficitPegged(-1)).toBe(true);
expect(isDeficitPegged(1)).toBe(true);
expect(isDeficitPegged(-0.99)).toBe(true);
expect(isDeficitPegged(0.995)).toBe(true);
});
it("is NOT pegged below the threshold (real differentiation)", () => {
expect(isDeficitPegged(-0.98)).toBe(false);
expect(isDeficitPegged(0.5)).toBe(false);
expect(isDeficitPegged(0)).toBe(false);
});
});
describe("deficitPegState — across horizons (#1958/#1959)", () => {
it("pegs at 1.00 on all horizons (затоварка) → sign 1", () => {
expect(deficitPegState(rows(-1, -1, -1, -1))).toEqual({
pegged: true,
sign: -1,
});
});
it("pegs at +1.00 on all horizons (острый дефицит) → sign +1", () => {
expect(deficitPegState(rows(1, 1, 1, 1))).toEqual({
pegged: true,
sign: 1,
});
});
it("does NOT peg when a row is mid-range (signal differentiates)", () => {
expect(deficitPegState(rows(-1, -1, -0.6, -1))).toEqual({
pegged: false,
sign: null,
});
});
it("does NOT peg when saturated rows have opposite signs", () => {
expect(deficitPegState(rows(-1, 1))).toEqual({
pegged: false,
sign: null,
});
});
it("ignores null deficit_index and pegs on present saturated rows only", () => {
expect(deficitPegState(rows(null, -1, null, -1))).toEqual({
pegged: true,
sign: -1,
});
});
it("is not pegged when no rows carry a deficit_index", () => {
expect(deficitPegState(rows(null, null))).toEqual({
pegged: false,
sign: null,
});
expect(deficitPegState([])).toEqual({ pegged: false, sign: null });
});
});

View file

@ -7,6 +7,7 @@
import type { BadgeVariant } from "@/components/ui/Badge";
import type {
ConfidenceLevel,
DemandSupplyForecast,
FutureSupply,
ProductMixEntry,
} from "@/types/forecast";
@ -14,6 +15,77 @@ import type {
/** 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).