/** * Industrial-listing label-density report (TEC-2769 — R5.2.2). * * Lightweight monitoring query. Answers: do we have enough labelled * listing-level data (with `priceUsdM2` populated) to train a listing-level * AVM for industrial real estate? * * Emits: * - Human-readable table to stdout (default). * - JSON blob to stdout when `--json` is passed (for CI / dashboards). * * Report sections: * - Totals (rows, rows with priceUsdM2, % label coverage). * - Histogram by VietnamRegion. * - Histogram by IndustrialPropertyType. * - Park coverage (parks with ≥1 listing / total parks). * - priceUsdM2 P10/P50/P90 by (region, propertyType). * - Acceptance-gate verdict for TEC-2769 (≥500 rows, NORTH/SOUTH ≥20%, * CENTRAL ≥5%, every propertyType enum ≥10 rows). * * Usage: * pnpm tsx scripts/report-industrial-label-density.ts * pnpm tsx scripts/report-industrial-label-density.ts --json */ import { PrismaPg } from '@prisma/adapter-pg'; import { IndustrialPropertyType, PrismaClient, VietnamRegion, } from '@prisma/client'; import pg from 'pg'; // --------------------------------------------------------------------------- // Types. // --------------------------------------------------------------------------- type Verdict = 'PASS' | 'FAIL'; interface Percentiles { p10: number | null; p50: number | null; p90: number | null; n: number; } interface Report { generatedAt: string; totals: { listings: number; withPriceUsdM2: number; labelCoveragePct: number; }; regionHistogram: Record; propertyTypeHistogram: Record; parkCoverage: { parksWithListings: number; parksTotal: number; coveragePct: number; }; priceBuckets: Array<{ region: VietnamRegion; propertyType: IndustrialPropertyType; percentiles: Percentiles; }>; gates: { totalAtLeast500: Verdict; northAtLeast20pct: Verdict; southAtLeast20pct: Verdict; centralAtLeast5pct: Verdict; everyPropertyTypeAtLeast10: Verdict; overall: Verdict; }; } // --------------------------------------------------------------------------- // Percentile helper. Linear interpolation on sorted array. // --------------------------------------------------------------------------- function percentile(sorted: number[], p: number): number | null { if (sorted.length === 0) return null; if (sorted.length === 1) return sorted[0] ?? null; const rank = (p / 100) * (sorted.length - 1); const lo = Math.floor(rank); const hi = Math.ceil(rank); if (lo === hi) return sorted[lo] ?? null; const loVal = sorted[lo] ?? 0; const hiVal = sorted[hi] ?? 0; return loVal + (hiVal - loVal) * (rank - lo); } function pct(x: number, total: number): number { if (total === 0) return 0; return Math.round((x / total) * 1000) / 10; } function fmtPrice(n: number | null): string { if (n == null) return ' — '; if (n >= 100) return n.toFixed(0).padStart(6); return n.toFixed(2).padStart(6); } // --------------------------------------------------------------------------- // Main. // --------------------------------------------------------------------------- async function run(): Promise { const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); const adapter = new PrismaPg(pool); const prisma = new PrismaClient({ adapter }); try { const parksTotal = await prisma.industrialPark.count(); const listings = await prisma.industrialListing.findMany({ select: { id: true, parkId: true, propertyType: true, priceUsdM2: true, park: { select: { region: true } }, }, }); const total = listings.length; const withPrice = listings.filter((l) => l.priceUsdM2 != null).length; // Region histogram. const regionHistogram: Report['regionHistogram'] = {}; for (const region of Object.values(VietnamRegion)) { regionHistogram[region] = { rows: 0, withPrice: 0 }; } for (const l of listings) { const r = l.park?.region; if (!r) continue; const bucket = regionHistogram[r]; if (!bucket) continue; bucket.rows += 1; if (l.priceUsdM2 != null) bucket.withPrice += 1; } // PropertyType histogram. const propertyTypeHistogram: Report['propertyTypeHistogram'] = {}; for (const pt of Object.values(IndustrialPropertyType)) { propertyTypeHistogram[pt] = { rows: 0, withPrice: 0 }; } for (const l of listings) { const bucket = propertyTypeHistogram[l.propertyType]; if (!bucket) continue; bucket.rows += 1; if (l.priceUsdM2 != null) bucket.withPrice += 1; } // Park coverage. const parkIdsWithListings = new Set(listings.map((l) => l.parkId)); const parkCoverage = { parksWithListings: parkIdsWithListings.size, parksTotal, coveragePct: pct(parkIdsWithListings.size, parksTotal), }; // Price percentiles by (region, propertyType). const priceBuckets: Report['priceBuckets'] = []; for (const region of Object.values(VietnamRegion)) { for (const propertyType of Object.values(IndustrialPropertyType)) { const values = listings .filter( (l) => l.park?.region === region && l.propertyType === propertyType && l.priceUsdM2 != null, ) .map((l) => Number(l.priceUsdM2)) .sort((a, b) => a - b); priceBuckets.push({ region, propertyType, percentiles: { n: values.length, p10: percentile(values, 10), p50: percentile(values, 50), p90: percentile(values, 90), }, }); } } // Acceptance gates. const northPct = pct(regionHistogram[VietnamRegion.NORTH]?.rows ?? 0, total); const southPct = pct(regionHistogram[VietnamRegion.SOUTH]?.rows ?? 0, total); const centralPct = pct( regionHistogram[VietnamRegion.CENTRAL]?.rows ?? 0, total, ); const everyPropertyTypeAtLeast10: Verdict = Object.values( propertyTypeHistogram, ).every((b) => b.rows >= 10) ? 'PASS' : 'FAIL'; const gates: Report['gates'] = { totalAtLeast500: total >= 500 ? 'PASS' : 'FAIL', northAtLeast20pct: northPct >= 20 ? 'PASS' : 'FAIL', southAtLeast20pct: southPct >= 20 ? 'PASS' : 'FAIL', centralAtLeast5pct: centralPct >= 5 ? 'PASS' : 'FAIL', everyPropertyTypeAtLeast10, overall: 'FAIL', }; gates.overall = gates.totalAtLeast500 === 'PASS' && gates.northAtLeast20pct === 'PASS' && gates.southAtLeast20pct === 'PASS' && gates.centralAtLeast5pct === 'PASS' && gates.everyPropertyTypeAtLeast10 === 'PASS' ? 'PASS' : 'FAIL'; return { generatedAt: new Date().toISOString(), totals: { listings: total, withPriceUsdM2: withPrice, labelCoveragePct: pct(withPrice, total), }, regionHistogram, propertyTypeHistogram, parkCoverage, priceBuckets, gates, }; } finally { await prisma.$disconnect(); await pool.end(); } } function renderHuman(report: Report): string { const out: string[] = []; const push = (s = ''): void => { out.push(s); }; push(''); push('━'.repeat(72)); push(' Industrial listing — label density report'); push(` generated: ${report.generatedAt}`); push('━'.repeat(72)); push(''); push(' TOTALS'); push(` listings: ${report.totals.listings}`); push( ` with priceUsdM2: ${report.totals.withPriceUsdM2} ` + `(${report.totals.labelCoveragePct}%)`, ); push(''); push(' BY REGION'); push(' region | rows | withPrice'); push(' ---------+------+----------'); for (const [region, b] of Object.entries(report.regionHistogram)) { push( ` ${region.padEnd(8)} | ${String(b.rows).padStart(4)} | ${String( b.withPrice, ).padStart(8)}`, ); } push(''); push(' BY PROPERTY TYPE'); push(' type | rows | withPrice'); push(' --------------------+------+----------'); for (const [pt, b] of Object.entries(report.propertyTypeHistogram)) { push( ` ${pt.padEnd(19)} | ${String(b.rows).padStart(4)} | ${String( b.withPrice, ).padStart(8)}`, ); } push(''); push(' PARK COVERAGE'); push( ` ${report.parkCoverage.parksWithListings} / ${report.parkCoverage.parksTotal} ` + `parks have ≥1 listing (${report.parkCoverage.coveragePct}%)`, ); push(''); push(' priceUsdM2 P10/P50/P90 (USD/m², USD/m²/mo, or USD/m²/yr by type)'); push(' region | type | n | P10 | P50 | P90'); push(' ---------+---------------------+-----+--------+--------+-------'); for (const bucket of report.priceBuckets) { if (bucket.percentiles.n === 0) continue; push( ` ${bucket.region.padEnd(8)} | ${bucket.propertyType.padEnd(19)} | ` + `${String(bucket.percentiles.n).padStart(3)} | ${fmtPrice( bucket.percentiles.p10, )} | ${fmtPrice(bucket.percentiles.p50)} | ${fmtPrice( bucket.percentiles.p90, )}`, ); } push(''); push(' ACCEPTANCE GATES (TEC-2769)'); push(` total ≥ 500 rows: ${report.gates.totalAtLeast500}`); push(` NORTH ≥ 20% of rows: ${report.gates.northAtLeast20pct}`); push(` SOUTH ≥ 20% of rows: ${report.gates.southAtLeast20pct}`); push(` CENTRAL ≥ 5% of rows: ${report.gates.centralAtLeast5pct}`); push( ` every propertyType ≥ 10 rows: ${report.gates.everyPropertyTypeAtLeast10}`, ); push(` OVERALL: ${report.gates.overall}`); push(''); return out.join('\n'); } async function main(): Promise { const asJson = process.argv.includes('--json'); try { const report = await run(); if (asJson) { console.log(JSON.stringify(report, null, 2)); } else { console.log(renderHuman(report)); } process.exit(report.gates.overall === 'PASS' ? 0 : 1); } catch (err) { console.error('Label-density report failed:', err); process.exit(2); } } if (require.main === module) { void main(); }