import type { Band, Enterprise, ForecastPoint, ReasonCode, Sector, StressScore, SubScores, TripWire, } from '../api' import { monthWindow, pickInt, pickOne, rng, seedOf, shuffled } from './seed' /* ------------------------------------------------------------------ */ /* The demo portfolio: 142 enterprises across 5 sector packs and 4 */ /* blocks. The six named on the deck's wireframe slide are pinned to */ /* their exact submitted scores; the rest are generated deterministically*/ /* so band counts always total 9 / 31 / 102. */ /* ------------------------------------------------------------------ */ export const BANDS: Record = { RED: { label: 'High risk', labelHi: 'ज़्यादा जोखिम', cls: 'band-red', dot: 'bg-risk-red' }, WATCH: { label: 'Watch', labelHi: 'निगरानी', cls: 'band-watch', dot: 'bg-amber' }, STANDARD: { label: 'Standard', labelHi: 'ठीक', cls: 'band-standard', dot: 'bg-green' }, } /** Band thresholds map to the institution's own watchlist categories (deck slide 3). */ export const BAND_THRESHOLD = { red: 65, watch: 35 } export function bandOf(score: number): Band { if (score >= BAND_THRESHOLD.red) return 'RED' if (score >= BAND_THRESHOLD.watch) return 'WATCH' return 'STANDARD' } export const SECTORS: Record = { DAIRY: { label: 'Dairy', labelHi: 'डेयरी', short: 'Dairy' }, POULTRY: { label: 'Poultry', labelHi: 'मुर्गी पालन', short: 'Poultry' }, FOOD_PROC: { label: 'Food processing', labelHi: 'खाद्य प्रसंस्करण', short: 'Food proc.' }, HANDICRAFTS: { label: 'Handicrafts', labelHi: 'हस्तशिल्प', short: 'Handicrafts' }, RURAL_RETAIL: { label: 'Rural retail', labelHi: 'ग्रामीण दुकान', short: 'Rural retail' }, } export const SECTOR_LIST = Object.keys(SECTORS) as Sector[] export const BLOCKS = ['Sehore', 'Vidisha', 'Raisen', 'Bhopal'] as const export const BLOCK_DISTRICT: Record = { Sehore: 'Sehore', Vidisha: 'Vidisha', Raisen: 'Raisen', Bhopal: 'Bhopal', } /** The six enterprises printed on the wireframe slide — reproduced exactly. */ const PINNED: { enterpriseId: string; name: string; nameHi: string; sector: Sector; block: string; score: number }[] = [ { enterpriseId: 'MSME-4417', name: 'Sehore Poultry Farm', nameHi: 'सीहोर पोल्ट्री फ़ार्म', sector: 'POULTRY', block: 'Sehore', score: 79 }, { enterpriseId: 'MSME-3902', name: 'Sharda Dairy Collective', nameHi: 'शारदा डेयरी समूह', sector: 'DAIRY', block: 'Vidisha', score: 71 }, { enterpriseId: 'MSME-5120', name: 'Anand Food Products', nameHi: 'आनंद फ़ूड प्रोडक्ट्स', sector: 'FOOD_PROC', block: 'Raisen', score: 58 }, { enterpriseId: 'MSME-2861', name: 'Gramin Kirana Store', nameHi: 'ग्रामीण किराना स्टोर', sector: 'RURAL_RETAIL', block: 'Sehore', score: 41 }, { enterpriseId: 'MSME-6033', name: 'Meera Handicrafts', nameHi: 'मीरा हस्तशिल्प', sector: 'HANDICRAFTS', block: 'Bhopal', score: 22 }, { enterpriseId: 'MSME-4488', name: 'Krishna Dairy Unit', nameHi: 'कृष्णा डेयरी यूनिट', sector: 'DAIRY', block: 'Vidisha', score: 18 }, ] const NAME_PREFIX = [ 'Shri', 'Maa Narmada', 'Jai Kisan', 'Sarvodaya', 'Annapurna', 'Vindhya', 'Betwa', 'Narmada', 'Adarsh', 'Nav Jeevan', 'Gram Shakti', 'Kisan Mitra', 'Sanskar', 'Prerna', 'Sunrise', 'Bhagirath', 'Malwa', 'Sahyog', 'Ekta', 'Pragati', 'Suvidha', 'Anmol', 'Nirmal', 'Vaibhav', ] const NAME_PREFIX_HI = [ 'श्री', 'माँ नर्मदा', 'जय किसान', 'सर्वोदय', 'अन्नपूर्णा', 'विंध्य', 'बेतवा', 'नर्मदा', 'आदर्श', 'नव जीवन', 'ग्राम शक्ति', 'किसान मित्र', 'संस्कार', 'प्रेरणा', 'सनराइज़', 'भगीरथ', 'मालवा', 'सहयोग', 'एकता', 'प्रगति', 'सुविधा', 'अनमोल', 'निर्मल', 'वैभव', ] const SECTOR_SUFFIX: Record = { DAIRY: { en: ['Dairy', 'Milk Centre', 'Dairy Unit', 'Doodh Sangh'], hi: ['डेयरी', 'दूध केंद्र', 'डेयरी यूनिट', 'दूध संघ'] }, POULTRY: { en: ['Poultry Farm', 'Layer Farm', 'Poultry Unit'], hi: ['पोल्ट्री फ़ार्म', 'लेयर फ़ार्म', 'पोल्ट्री यूनिट'] }, FOOD_PROC: { en: ['Food Products', 'Flour Mill', 'Masala Udyog', 'Agro Foods'], hi: ['फ़ूड प्रोडक्ट्स', 'आटा चक्की', 'मसाला उद्योग', 'एग्रो फ़ूड्स'] }, HANDICRAFTS: { en: ['Handicrafts', 'Handloom', 'Craft Works'], hi: ['हस्तशिल्प', 'हथकरघा', 'क्राफ़्ट वर्क्स'] }, RURAL_RETAIL: { en: ['Kirana Store', 'General Store', 'Provision Store'], hi: ['किराना स्टोर', 'जनरल स्टोर', 'प्रोविज़न स्टोर'] }, } /** Sector weights applied to the five sub-scores (deck feature 2). */ export const SECTOR_WEIGHTS: Record = { DAIRY: { liquidity: 0.28, behavioural: 0.2, trendVolatility: 0.18, obligationLoad: 0.19, externalShock: 0.15 }, POULTRY: { liquidity: 0.3, behavioural: 0.22, trendVolatility: 0.17, obligationLoad: 0.14, externalShock: 0.17 }, FOOD_PROC: { liquidity: 0.26, behavioural: 0.22, trendVolatility: 0.2, obligationLoad: 0.18, externalShock: 0.14 }, HANDICRAFTS: { liquidity: 0.24, behavioural: 0.18, trendVolatility: 0.2, obligationLoad: 0.16, externalShock: 0.22 }, RURAL_RETAIL: { liquidity: 0.3, behavioural: 0.24, trendVolatility: 0.18, obligationLoad: 0.18, externalShock: 0.1 }, } /** Band plan so the totals always match the deck: 9 red, 31 watch, 102 standard. */ const TOTAL = 142 const TARGET = { RED: 9, WATCH: 31, STANDARD: 102 } function buildBandPlan(): Band[] { const remaining: Band[] = [] const pinnedCounts = PINNED.reduce>( (acc, p) => { acc[bandOf(p.score)]++ return acc }, { RED: 0, WATCH: 0, STANDARD: 0 }, ) ;(Object.keys(TARGET) as Band[]).forEach((b) => { for (let i = 0; i < TARGET[b] - pinnedCounts[b]; i++) remaining.push(b) }) return shuffled(rng(seedOf('udyamlinc-band-plan')), remaining) } /** * Generated scores stay below the headline enterprise's 79, so Sehore Poultry * Farm leads the dashboard exactly as it does on the deck's wireframe. */ function scoreForBand(r: () => number, band: Band): number { if (band === 'RED') return pickInt(r, 66, 78) if (band === 'WATCH') return pickInt(r, 36, 64) return pickInt(r, 8, 34) } /** A 6-month score history that lands on the current score, drifting the way the band implies. */ function trendFor(r: () => number, score: number, band: Band): number[] { const drift = band === 'RED' ? 1 : band === 'WATCH' ? 0.4 : -0.6 const out: number[] = [] let v = score - drift * 9 for (let i = 0; i < 6; i++) { out.push(Math.max(4, Math.min(97, Math.round(v)))) v += drift * 3 + (r() - 0.5) * 7 } out[5] = score return out } function build(): Enterprise[] { const plan = buildBandPlan() const list: Enterprise[] = [] PINNED.forEach((p, i) => { const r = rng(seedOf(p.enterpriseId)) const band = bandOf(p.score) list.push({ id: i + 1, enterpriseId: p.enterpriseId, name: p.name, nameHi: p.nameHi, sector: p.sector, block: p.block, district: BLOCK_DISTRICT[p.block], ownerCount: pickInt(r, 1, 4), vintageMonths: pickInt(r, 14, 96), loanOutstanding: pickInt(r, 6, 34) * 10_000, monthlyInstalment: pickInt(r, 24, 96) * 250, score: p.score, band, trend: trendFor(r, p.score, band), lastUpdated: new Date().toISOString(), }) }) for (let i = 0; i < TOTAL - PINNED.length; i++) { const enterpriseId = `MSME-${1000 + ((i * 37 + 113) % 8000)}` const r = rng(seedOf(enterpriseId)) const sector = pickOne(r, SECTOR_LIST) const block = pickOne(r, BLOCKS) const band = plan[i] const score = scoreForBand(r, band) const nameIdx = pickInt(r, 0, NAME_PREFIX.length - 1) const sufIdx = pickInt(r, 0, SECTOR_SUFFIX[sector].en.length - 1) list.push({ id: PINNED.length + i + 1, enterpriseId, name: `${NAME_PREFIX[nameIdx]} ${SECTOR_SUFFIX[sector].en[sufIdx]}`, nameHi: `${NAME_PREFIX_HI[nameIdx]} ${SECTOR_SUFFIX[sector].hi[sufIdx]}`, sector, block, district: BLOCK_DISTRICT[block], ownerCount: pickInt(r, 1, 4), vintageMonths: pickInt(r, 8, 120), loanOutstanding: pickInt(r, 4, 40) * 10_000, monthlyInstalment: pickInt(r, 16, 110) * 250, score, band, trend: trendFor(r, score, band), lastUpdated: new Date().toISOString(), }) } // Highest risk first — the order the console works the book in. return list.sort((a, b) => b.score - a.score) } /** The whole demo portfolio, built once per page load. */ export const ENTERPRISES: Enterprise[] = build() export function findEnterprise(enterpriseId: string): Enterprise | undefined { return ENTERPRISES.find((e) => e.enterpriseId === enterpriseId) } // ── Sub-scores, drivers and trip-wires ────────────────────────── /** Slide-6 driver weights for the pinned enterprise, so the demo matches the submission. */ const PINNED_SUBSCORES: Record = { 'MSME-4417': { liquidity: 26, behavioural: 19, trendVolatility: 14, obligationLoad: 11, externalShock: 9 }, } export function subScoresFor(e: Enterprise): SubScores { const pinned = PINNED_SUBSCORES[e.enterpriseId] if (pinned) return pinned const r = rng(seedOf(`${e.enterpriseId}:sub`)) const w = SECTOR_WEIGHTS[e.sector] // Distribute the composite across the five families, weighted by sector, with jitter. const raw = { liquidity: e.score * w.liquidity * (0.8 + r() * 0.5), behavioural: e.score * w.behavioural * (0.8 + r() * 0.5), trendVolatility: e.score * w.trendVolatility * (0.8 + r() * 0.5), obligationLoad: e.score * w.obligationLoad * (0.8 + r() * 0.5), externalShock: e.score * w.externalShock * (0.8 + r() * 0.5), } return { liquidity: Math.round(raw.liquidity), behavioural: Math.round(raw.behavioural), trendVolatility: Math.round(raw.trendVolatility), obligationLoad: Math.round(raw.obligationLoad), externalShock: Math.round(raw.externalShock), } } export function stressScoreFor(e: Enterprise): StressScore { return { enterpriseId: e.enterpriseId, score: e.score, band: e.band, subScores: subScoresFor(e), computedAt: new Date().toISOString(), modelVersion: 'lgbm-q-v0.9.3', } } export const DRIVER_META: { key: keyof SubScores; label: string; labelHi: string }[] = [ { key: 'liquidity', label: 'Liquidity', labelHi: 'नकदी' }, { key: 'behavioural', label: 'Behavioural (UPI)', labelHi: 'लेन-देन व्यवहार' }, { key: 'trendVolatility', label: 'Trend & volatility', labelHi: 'उतार-चढ़ाव' }, { key: 'obligationLoad', label: 'Obligation load', labelHi: 'किस्त का बोझ' }, { key: 'externalShock', label: 'External shock', labelHi: 'बाहरी झटका' }, ] /** Sector-specific sentence templates — the deck's "models the trade, not one generic model". */ const REASON_TEXT: Record> = { POULTRY: { liquidity: { en: 'Feed costs up 18% against a 9% fall in egg realisation.', hi: 'दाना 18% महँगा और अंडे का भाव 9% गिरा है।' }, behavioural: { en: 'UPI collections down 23% over eight weeks.', hi: 'आठ हफ़्तों में UPI से आया पैसा 23% घटा है।' }, trendVolatility: { en: 'Weekly sales swinging twice as wide as last season.', hi: 'हफ़्तेवार बिक्री पिछले सीज़न से दोगुनी ऊपर-नीचे हो रही है।' }, obligationLoad: { en: 'Instalment now takes 41% of net monthly cash.', hi: 'किस्त अब महीने की कमाई का 41% ले रही है।' }, externalShock: { en: 'Heat anomaly flagged in the block for the next fortnight.', hi: 'अगले पंद्रह दिन ब्लॉक में गर्मी की चेतावनी है।' }, }, DAIRY: { liquidity: { en: 'Fodder cost up 14% while procurement rate stayed flat.', hi: 'चारा 14% महँगा हुआ, दूध का रेट वही है।' }, behavioural: { en: 'Society payment cycle stretched from 10 to 18 days.', hi: 'समिति से भुगतान 10 दिन की जगह 18 दिन में आ रहा है।' }, trendVolatility: { en: 'Daily yield varying sharply across the last six weeks.', hi: 'छह हफ़्तों में रोज़ का दूध बहुत घट-बढ़ रहा है।' }, obligationLoad: { en: 'Two instalments now fall in the same fortnight.', hi: 'दो किस्तें एक ही पखवाड़े में आ रही हैं।' }, externalShock: { en: 'Rainfall deficit in the block is raising fodder prices.', hi: 'ब्लॉक में कम बारिश से चारा महँगा हो रहा है।' }, }, FOOD_PROC: { liquidity: { en: 'Raw material prepayment is locking up working capital.', hi: 'कच्चे माल का अग्रिम भुगतान पूँजी रोक रहा है।' }, behavioural: { en: 'Buyer settlements slipping past the agreed 15 days.', hi: 'खरीदार 15 दिन की जगह देर से पैसा दे रहे हैं।' }, trendVolatility: { en: 'Order volume swinging with festival demand.', hi: 'त्योहारों के साथ ऑर्डर बहुत घट-बढ़ रहे हैं।' }, obligationLoad: { en: 'Instalment plus rent exceeds 50% of monthly inflow.', hi: 'किस्त और किराया मिलकर आधी से ज़्यादा कमाई ले रहे हैं।' }, externalShock: { en: 'Mandi price shock in the primary input crop.', hi: 'मुख्य फ़सल के मंडी भाव में तेज़ बदलाव है।' }, }, HANDICRAFTS: { liquidity: { en: 'Finished stock is unsold going into the lean quarter.', hi: 'तैयार माल बिका नहीं और सुस्त तिमाही आ रही है।' }, behavioural: { en: 'Top buyer accounts for 68% of all receipts.', hi: 'कुल आमदनी का 68% सिर्फ़ एक खरीदार से आता है।' }, trendVolatility: { en: 'Receipts concentrated in two months of the year.', hi: 'साल की कमाई सिर्फ़ दो महीनों में सिमटी है।' }, obligationLoad: { en: 'Repayment schedule ignores the seasonal lull.', hi: 'किस्त का समय सुस्त सीज़न का ध्यान नहीं रखता।' }, externalShock: { en: 'Exhibition calendar thinner than last year.', hi: 'पिछले साल से कम प्रदर्शनियाँ लगी हैं।' }, }, RURAL_RETAIL: { liquidity: { en: 'Credit sales to customers up 31% this quarter.', hi: 'इस तिमाही उधार बिक्री 31% बढ़ी है।' }, behavioural: { en: 'Daily UPI receipt count down for six straight weeks.', hi: 'छह हफ़्तों से रोज़ के UPI लेन-देन घट रहे हैं।' }, trendVolatility: { en: 'Footfall dropping since the new road closure.', hi: 'रास्ता बंद होने से ग्राहक कम आ रहे हैं।' }, obligationLoad: { en: 'Stock restocking clashes with the instalment date.', hi: 'माल भरने और किस्त की तारीख़ एक साथ पड़ रही है।' }, externalShock: { en: 'Local market disruption flagged in the block.', hi: 'ब्लॉक में स्थानीय बाज़ार में गड़बड़ी दर्ज है।' }, }, } /** Top-3 SHAP drivers as plain-language sentences (deck feature 4). */ export function reasonCodesFor(e: Enterprise): ReasonCode[] { const sub = subScoresFor(e) return DRIVER_META.map((d) => ({ driver: d.label, driverHi: d.labelHi, weight: sub[d.key], sentenceEn: REASON_TEXT[e.sector][d.key].en, sentenceHi: REASON_TEXT[e.sector][d.key].hi, })) .sort((a, b) => b.weight - a.weight) .slice(0, 3) } /** Rule-based alerts that fire independently of the model, for auditability (feature 3). */ export function tripWiresFor(e: Enterprise): TripWire[] { const r = rng(seedOf(`${e.enterpriseId}:tw`)) const sub = subScoresFor(e) const dscr = (1.9 - e.score / 100).toFixed(2) return [ { code: 'TW-01', label: 'Projected DSCR below 1.0', labelHi: 'किस्त चुकाने की क्षमता 1.0 से नीचे', fired: e.band === 'RED', detail: `P10 debt-service coverage projected at ${dscr}`, threshold: 'DSCR(P10) < 1.00', }, { code: 'TW-02', label: 'UPI inflow drop over 8 weeks', labelHi: 'आठ हफ़्तों में UPI आमदनी में गिरावट', fired: sub.behavioural >= 15, detail: `Rolling 8-week inflow down ${pickInt(r, 12, 34)}%`, threshold: 'Δ inflow < −20%', }, { code: 'TW-03', label: 'Ledger silent for 14 days', labelHi: '14 दिन से हिसाब दर्ज नहीं', fired: e.band !== 'STANDARD' && r() > 0.55, detail: `Last entry ${pickInt(r, 2, 26)} days ago`, threshold: 'no entry ≥ 14 days', }, { code: 'TW-04', label: 'Instalment share of inflow above 40%', labelHi: 'किस्त आमदनी का 40% से ज़्यादा', fired: sub.obligationLoad >= 11, detail: `Instalment is ${pickInt(r, 22, 58)}% of median monthly inflow`, threshold: 'EMI / inflow > 40%', }, { code: 'TW-05', label: 'Climate or mandi shock in block', labelHi: 'ब्लॉक में मौसम या मंडी का झटका', fired: sub.externalShock >= 9, detail: 'IMD heat anomaly + Agmarknet input price spike', threshold: 'anomaly z-score > 1.5', }, ] } // ── Forecast ──────────────────────────────────────────────────── /** * Six-month quantile path. P50 drifts down for stressed enterprises; P10/P90 * fan out with horizon, because uncertainty compounds. The obligation line is * the instalment plus fixed costs due that month — where P10 dips under it, * the enterprise escalates. */ export function forecastFor(e: Enterprise, horizon = 6): ForecastPoint[] { const r = rng(seedOf(`${e.enterpriseId}:fc`)) const months = monthWindow(horizon) const base = 18_000 + (100 - e.score) * 420 + pickInt(r, -2000, 2500) const drift = e.band === 'RED' ? -0.14 : e.band === 'WATCH' ? -0.05 : 0.035 const fixed = e.monthlyInstalment + pickInt(r, 8, 22) * 400 const points = months.map((m, h) => { const centre = base * (1 + drift * (h + 1)) + (r() - 0.5) * 1800 const spread = centre * (0.16 + h * 0.045) + 900 return { month: m.key, label: m.label, labelHi: m.labelHi, p10: Math.round(centre - spread), p50: Math.round(centre), p90: Math.round(centre + spread * 0.85), obligation: Math.round(fixed + h * 120), } }) // The wireframe slide quotes a ₹12,400 shortfall three months out for the // headline enterprise — pin it so the prototype matches the submitted deck. const pin = PINNED_SHORTFALL[e.enterpriseId] if (pin && points.length > pin.horizon) { const p = points[pin.horizon] p.p10 = p.obligation - pin.amount p.p50 = Math.max(p.p50, p.p10 + 4_000) p.p90 = Math.max(p.p90, p.p50 + 5_000) // Everything before the pinned month must still clear its obligation, // or the "first breach" would land earlier than the deck says. for (let i = 0; i < pin.horizon; i++) { points[i].p10 = Math.max(points[i].p10, points[i].obligation + 1_500) points[i].p50 = Math.max(points[i].p50, points[i].p10 + 3_000) points[i].p90 = Math.max(points[i].p90, points[i].p50 + 4_000) } } return points } /** Enterprise → { months ahead of the first breach, ₹ gap }, as printed on the deck. */ const PINNED_SHORTFALL: Record = { 'MSME-4417': { horizon: 2, amount: 12_400 }, } /** First month where the downside case fails to cover obligations, or null. */ export function breachPoint(points: ForecastPoint[]): ForecastPoint | null { return points.find((p) => p.p10 < p.obligation) ?? null } /** Size of the projected gap at the breach month. */ export function shortfallOf(points: ForecastPoint[]): number { const b = breachPoint(points) return b ? Math.max(0, b.obligation - b.p10) : 0 }