334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
/**
|
|
* 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<string, { rows: number; withPrice: number }>;
|
|
propertyTypeHistogram: Record<string, { rows: number; withPrice: number }>;
|
|
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<Report> {
|
|
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<void> {
|
|
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();
|
|
}
|