Files
goodgo-platform/tmp/tec2773-industrial-stash/scripts/report-industrial-label-density.ts

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();
}