scripts/analysis/miami-enrollment-descriptives.ts
Served verbatim from the project repository. Internal working document conventions apply: documents may reference file paths, branch names, and findings-ledger anchors from the repo.
// Miami-Dade enrollment / capacity / closure descriptives packet — the
// analysis behind /miami/methodology/analysis/enrollment. Writes
// data/analysis/miami-enrollment.json; the page renders ONLY numbers from that
// file. Every input is a served cell that passed the harness
// (docs/qa_reports/miami/__enrollment_full_qa.json); this script recomputes
// the descriptive statistics from those cells and records which cells it used.
//
// Universe definitions (stated, not assumed):
// district-run regular = school_type REGULAR and not a charter (any activity
// code; a school closed mid-window still counts in the years it reported).
// charter = is_charter. other = everything else (alternative, ESE centers,
// virtual, DJJ, adult, technical colleges).
//
// Run: npx tsx scripts/analysis/miami-enrollment-descriptives.ts
import fs from "node:fs";
import path from "node:path";
import { config as loadEnv } from "dotenv";
loadEnv({ path: path.join(process.cwd(), ".env.local") });
import pg from "pg";
const directUrl = (process.env.DIRECT_URL ?? process.env.POSTGRES_URL_NON_POOLING ?? process.env.DATABASE_URL)!;
function makePool() {
const u = new URL(directUrl);
const ssl = u.searchParams.get("sslmode") === "disable" ? false : ({ rejectUnauthorized: false } as const);
return new pg.Pool({ user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), host: u.hostname, port: parseInt(u.port || "5432", 10), database: u.pathname.slice(1), ssl, max: 2, statement_timeout: 120_000 });
}
const pool = makePool();
async function q<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
return (await pool.query(sql, params)).rows as T[];
}
const YEARS = ["2016-17", "2017-18", "2018-19", "2019-20", "2020-21", "2021-22", "2022-23", "2023-24", "2024-25", "2025-26"] as const;
type Year = (typeof YEARS)[number];
const LATEST: Year = "2025-26";
const PRE_PANDEMIC: Year = "2019-20";
const FIRST: Year = "2016-17";
const FISH_YEAR: Year = "2024-25"; // latest state FISH report; also the year the 2026 letters' utilization figures describe
const GRADES = ["PK", "KG", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] as const;
const LEVELS = ["Elementary", "K-8", "Middle", "High", "6-12", "K-12"] as const;
type Level = (typeof LEVELS)[number];
const OUT = path.join(process.cwd(), "data", "analysis", "miami-enrollment.json");
// ── stats helpers ────────────────────────────────────────────────────────────
const r1 = (n: number) => Math.round(n * 10) / 10;
const r3 = (n: number) => Math.round(n * 1000) / 1000;
function quantile(sorted: number[], p: number): number {
if (!sorted.length) return NaN;
const pos = (sorted.length - 1) * p, lo = Math.floor(pos), hi = Math.ceil(pos);
return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}
const median = (xs: number[]) => quantile([...xs].sort((a, b) => a - b), 0.5);
const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
function pearson(pairs: [number, number][]): number {
const n = pairs.length; if (n < 3) return NaN;
const mx = sum(pairs.map((p) => p[0])) / n, my = sum(pairs.map((p) => p[1])) / n;
let sxy = 0, sxx = 0, syy = 0;
for (const [x, y] of pairs) { sxy += (x - mx) * (y - my); sxx += (x - mx) ** 2; syy += (y - my) ** 2; }
return sxy / Math.sqrt(sxx * syy);
}
function spearman(pairs: [number, number][]): number {
const rank = (v: number[]) => { const idx = v.map((x, i) => [x, i] as const).sort((a, b) => a[0] - b[0]); const r = new Array(v.length); let i = 0; while (i < idx.length) { let j = i; while (j + 1 < idx.length && idx[j + 1][0] === idx[i][0]) j++; const rk = (i + j) / 2 + 1; for (let k = i; k <= j; k++) r[idx[k][1]] = rk; i = j + 1; } return r as number[]; };
const rx = rank(pairs.map((p) => p[0])), ry = rank(pairs.map((p) => p[1]));
return pearson(rx.map((x, i) => [x, ry[i]] as [number, number]));
}
// Fisher exact (two-sided, sum of probabilities ≤ observed) via log-gamma.
function lgamma(z: number): number { const g = 7, c = [0.99999999999980993, 676.5203681218851, -1259.1392167224028, 771.32342877765313, -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7]; if (z < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * z)) - lgamma(1 - z); z -= 1; let x = c[0]; for (let i = 1; i < g + 2; i++) x += c[i] / (z + i); const t = z + g + 0.5; return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x); }
const lchoose = (n: number, k: number) => lgamma(n + 1) - lgamma(k + 1) - lgamma(n - k + 1);
function fisherTwoSided(a: number, b: number, c: number, d: number): number {
const r1 = a + b, r2 = c + d, c1 = a + c, n = a + b + c + d;
const logp = (x: number) => lchoose(r1, x) + lchoose(r2, c1 - x) - lchoose(n, c1);
const obs = logp(a); let p = 0;
for (let x = Math.max(0, c1 - r2); x <= Math.min(r1, c1); x++) { const lp = logp(x); if (lp <= obs + 1e-9) p += Math.exp(lp); }
return Math.min(1, p);
}
function stats(xs: number[]) {
const s = [...xs].sort((a, b) => a - b);
return { n: s.length, mean: s.length ? r1(sum(s) / s.length) : null, p10: r1(quantile(s, 0.1)), p25: r1(quantile(s, 0.25)), p50: r1(quantile(s, 0.5)), p75: r1(quantile(s, 0.75)), p90: r1(quantile(s, 0.9)), min: s.length ? r1(s[0]) : null, max: s.length ? r1(s[s.length - 1]) : null };
}
type SchoolRow = { school_number: string; name: string; school_type: string; is_charter: boolean; school_level: string | null; activity_code: string | null; region: string | null; include: boolean };
type Cell = { school_number: string; year: string; metric_key: string; value: number | null; suppressed: boolean };
async function main() {
const schools = await q<SchoolRow>(`SELECT school_number, name, school_type, is_charter, school_level, activity_code, region, include_in_default_comparisons AS include FROM miami_schools`);
const byNum = new Map(schools.map((s) => [s.school_number, s]));
const sector = (s: SchoolRow) => (s.is_charter ? "charter" : s.school_type === "REGULAR" ? "district_run_regular" : "other");
const cells = await q<Cell>(`SELECT school_number, year, metric_key, value::float8 AS value, suppressed FROM miami_school_year_metrics WHERE subgroup = 'ALL' AND metric_key = ANY($1)`,
[["enrollment_oct", "enrollment_oct_gPK", "enrollment_oct_gKG", ...GRADES.filter((g) => !["PK", "KG"].includes(g)).map((g) => `enrollment_oct_g${g}`), "fish_capacity_permanent", "fish_capacity_school", "cofte", "utilization_permanent_pct", "utilization_school_capacity_pct", "per_pupil_expenditure_school", "per_pupil_expenditure_total", "students_per_teacher", "teacher_fte", "enrollment_change_5yr_pct"]]);
const idx = new Map<string, number>();
for (const c of cells) if (c.value !== null && !c.suppressed) idx.set(`${c.school_number}|${c.year}|${c.metric_key}`, c.value);
const get = (n: string, y: string, k: string): number | null => idx.get(`${n}|${y}|${k}`) ?? null;
const events = await q<{ school_number: string; school_year: string; action: string; detail: string | null; source_doc_url: string | null; source_page: number | null; board_item: string | null; decided_at: string | null }>(
`SELECT school_number, school_year, action, detail, source_doc_url, source_page, board_item, decided_at::text FROM miami_school_events ORDER BY school_year, action, school_number`);
const loads = await q<{ source_id: string; sha256: string | null; year_covered: string | null; loaded_at: string }>(`SELECT DISTINCT ON (source_id) source_id, sha256, year_covered, loaded_at::text FROM miami_data_loads ORDER BY source_id, loaded_at DESC`);
const qa = JSON.parse(fs.readFileSync(path.join(process.cwd(), "docs", "qa_reports", "miami", "__enrollment_full_qa.json"), "utf-8"));
// 1. District trend by sector
type SectorCell = { students: number; schools: number };
const district_trend: Record<string, { district_run_regular: SectorCell; charter: SectorCell; other: SectorCell; total: SectorCell; charter_share_pct: number }> = {};
for (const y of YEARS) {
const acc: { district_run_regular: SectorCell; charter: SectorCell; other: SectorCell } = { district_run_regular: { students: 0, schools: 0 }, charter: { students: 0, schools: 0 }, other: { students: 0, schools: 0 } };
for (const s of schools) { const v = get(s.school_number, y, "enrollment_oct"); if (v === null) continue; const k = sector(s) as keyof typeof acc; acc[k].students += v; acc[k].schools++; }
const total = { students: acc.district_run_regular.students + acc.charter.students + acc.other.students, schools: acc.district_run_regular.schools + acc.charter.schools + acc.other.schools };
district_trend[y] = { ...acc, total, charter_share_pct: r1((acc.charter.students / total.students) * 100) };
}
// 2. Grade pipeline (district-wide, all sectors; and district-run only)
const pipelineYears: Year[] = [FIRST, PRE_PANDEMIC, "2023-24", LATEST];
const grade_pipeline: Record<string, Record<string, { all: number; district_run_regular: number }>> = {};
const pipeline_ratios: Record<string, { k_to_g12: number; k_to_g5: number }> = {};
for (const y of YEARS) {
const g: Record<string, { all: number; district_run_regular: number }> = {};
for (const gr of GRADES) {
let all = 0, dr = 0;
for (const s of schools) { const v = get(s.school_number, y, `enrollment_oct_g${gr}`); if (v === null) continue; all += v; if (sector(s) === "district_run_regular") dr += v; }
g[gr] = { all: Math.round(all), district_run_regular: Math.round(dr) };
}
if (pipelineYears.includes(y)) grade_pipeline[y] = g;
pipeline_ratios[y] = { k_to_g12: r3(g.KG.all / g["12"].all), k_to_g5: r3(g.KG.all / g["5"].all) };
}
// 3. School-level change for continuing district-run regular schools
function changeWindow(from: Year, to: Year) {
const rows: { school_number: string; name: string; level: Level | "Other"; from: number; to: number; pct: number }[] = [];
for (const s of schools) {
if (sector(s) !== "district_run_regular") continue;
const a = get(s.school_number, from, "enrollment_oct"), b = get(s.school_number, to, "enrollment_oct");
if (a === null || b === null || a <= 0 || b <= 0) continue;
rows.push({ school_number: s.school_number, name: s.name, level: (LEVELS as readonly string[]).includes(s.school_level ?? "") ? (s.school_level as Level) : "Other", from: a, to: b, pct: (b / a - 1) * 100 });
}
const pcts = rows.map((r) => r.pct);
const byLevel: Record<string, { n: number; from: number; to: number; pct: number; median_school_pct: number }> = {};
for (const lv of [...LEVELS, "Other"]) { const rr = rows.filter((r) => r.level === lv); if (!rr.length) continue; byLevel[lv] = { n: rr.length, from: sum(rr.map((r) => r.from)), to: sum(rr.map((r) => r.to)), pct: r1((sum(rr.map((r) => r.to)) / sum(rr.map((r) => r.from)) - 1) * 100), median_school_pct: r1(median(rr.map((r) => r.pct))) }; }
const bins = [-100, -50, -40, -33, -25, -20, -10, 0, 10, 20, 50, 1e9];
const histogram = bins.slice(0, -1).map((lo, i) => ({ lo, hi: bins[i + 1], n: pcts.filter((p) => p >= lo && p < bins[i + 1]).length }));
const charterRows: { from: number; to: number }[] = [];
for (const s of schools) { if (!s.is_charter) continue; const a = get(s.school_number, from, "enrollment_oct"), b = get(s.school_number, to, "enrollment_oct"); if (a === null || b === null || a <= 0 || b <= 0) continue; charterRows.push({ from: a, to: b }); }
return {
from, to, n: rows.length, from_total: sum(rows.map((r) => r.from)), to_total: sum(rows.map((r) => r.to)), pct: r1((sum(rows.map((r) => r.to)) / sum(rows.map((r) => r.from)) - 1) * 100),
school_pct: stats(pcts), share_le_minus10: r3(pcts.filter((p) => p <= -10).length / pcts.length), share_le_minus20: r3(pcts.filter((p) => p <= -20).length / pcts.length), share_le_minus25: r3(pcts.filter((p) => p <= -25).length / pcts.length), share_le_minus33: r3(pcts.filter((p) => p <= -33).length / pcts.length), n_grew: pcts.filter((p) => p > 0).length,
by_level: byLevel, histogram,
charters_continuing: { n: charterRows.length, from_total: sum(charterRows.map((r) => r.from)), to_total: sum(charterRows.map((r) => r.to)), pct: charterRows.length ? r1((sum(charterRows.map((r) => r.to)) / sum(charterRows.map((r) => r.from)) - 1) * 100) : null },
largest_losses: rows.filter((r) => r.from >= 200).sort((a, b) => a.pct - b.pct).slice(0, 15).map((r) => ({ ...r, pct: r1(r.pct) })),
};
}
const school_change = { pre_pandemic_to_latest: changeWindow(PRE_PANDEMIC, LATEST), first_to_latest: changeWindow(FIRST, LATEST), five_year: changeWindow("2020-21", LATEST) };
// 4. Utilization (FISH year), district-run regular schools with both fields
type U = { school_number: string; name: string; level: string; enrollment: number; permanent: number; school_capacity: number | null; util_headcount: number; util_state: number | null };
const utilRows: U[] = [];
for (const s of schools) {
if (sector(s) !== "district_run_regular") continue;
const e = get(s.school_number, FISH_YEAR, "enrollment_oct"), cap = get(s.school_number, FISH_YEAR, "fish_capacity_permanent"), u = get(s.school_number, FISH_YEAR, "utilization_permanent_pct");
if (e === null || cap === null || u === null || cap <= 0 || e <= 0) continue;
utilRows.push({ school_number: s.school_number, name: s.name, level: s.school_level ?? "Other", enrollment: e, permanent: cap, school_capacity: get(s.school_number, FISH_YEAR, "fish_capacity_school"), util_headcount: u, util_state: get(s.school_number, FISH_YEAR, "utilization_school_capacity_pct") });
}
const uh = utilRows.map((r) => r.util_headcount);
const us = utilRows.map((r) => r.util_state).filter((v): v is number => v !== null);
const utilBins = [0, 20, 33, 50, 67, 80, 90, 100, 120, 1e9];
const utilization = {
year: FISH_YEAR, definition_headcount: "October headcount ÷ permanent FISH capacity", definition_state: "COFTE ÷ school capacity incl. relocatables (as printed by the state)",
n: utilRows.length, headcount: stats(uh), state: stats(us),
thresholds_headcount: { below_33: uh.filter((v) => v < 33.33).length, below_50: uh.filter((v) => v < 50).length, below_67: uh.filter((v) => v < 66.67).length, below_80: uh.filter((v) => v < 80).length, at_or_above_100: uh.filter((v) => v >= 100).length },
thresholds_state: { below_33: us.filter((v) => v < 33.33).length, below_50: us.filter((v) => v < 50).length, below_67: us.filter((v) => v < 66.67).length, below_80: us.filter((v) => v < 80).length, at_or_above_100: us.filter((v) => v >= 100).length },
district_sum_headcount_pct: r1((sum(utilRows.map((r) => r.enrollment)) / sum(utilRows.map((r) => r.permanent))) * 100),
by_level: Object.fromEntries(LEVELS.map((lv) => { const rr = utilRows.filter((r) => r.level === lv); return [lv, { n: rr.length, median_headcount: rr.length ? r1(median(rr.map((r) => r.util_headcount))) : null, below_50: rr.filter((r) => r.util_headcount < 50).length, at_or_above_100: rr.filter((r) => r.util_headcount >= 100).length }]; })),
histogram_headcount: utilBins.slice(0, -1).map((lo, i) => ({ lo, hi: utilBins[i + 1], n: uh.filter((v) => v >= lo && v < utilBins[i + 1]).length })),
headcount_vs_state_pearson: r3(pearson(utilRows.filter((r) => r.util_state !== null).map((r) => [r.util_headcount, r.util_state as number]))),
};
// 5. Lift of the 2026 lists over size / utilization cuts
const flaggedBy = new Map<string, Set<string>>();
for (const e of events) { if ((e.school_year === "2026-27" && ["CLOSED", "CONSOLIDATED_INTO"].includes(e.action)) || (e.school_year === "2027-28" && ["REASSESSED", "UNDER_REVIEW"].includes(e.action))) { const s = flaggedBy.get(e.school_number) ?? new Set<string>(); s.add(e.action); flaggedBy.set(e.school_number, s); } }
const liftUniverse = utilRows.filter((r) => ["Elementary", "K-8", "Middle"].includes(r.level)).map((r) => ({ ...r, flagged: flaggedBy.has(r.school_number), actions: [...(flaggedBy.get(r.school_number) ?? [])] }));
const base = liftUniverse.filter((r) => r.flagged).length / liftUniverse.length;
function lift(label: string, pred: (r: (typeof liftUniverse)[number]) => boolean) {
const below = liftUniverse.filter(pred), above = liftUniverse.filter((r) => !pred(r));
const a = below.filter((r) => r.flagged).length, b = below.length - a, c = above.filter((r) => r.flagged).length, d = above.length - c;
const pb = a / Math.max(1, below.length), pa = c / Math.max(1, above.length);
return { cut: label, n_below: below.length, flagged_below: a, p_below: r3(pb), n_at_or_above: above.length, flagged_at_or_above: c, p_at_or_above: r3(pa), lift: pa > 0 ? r1(pb / pa) : null, fisher_p: fisherTwoSided(a, b, c, d) };
}
const lift_table = {
year_of_predictors: FISH_YEAR, universe: "district-run regular Elementary, K-8 and Middle schools with 2024-25 enrollment and permanent FISH capacity", n: liftUniverse.length, n_flagged: liftUniverse.filter((r) => r.flagged).length, base_rate: r3(base),
cuts: [
lift("utilization (headcount ÷ permanent) < 33%", (r) => r.util_headcount < 33.33), lift("utilization < 50%", (r) => r.util_headcount < 50), lift("utilization < 67%", (r) => r.util_headcount < 66.67),
lift("enrollment < 300", (r) => r.enrollment < 300), lift("enrollment < 400", (r) => r.enrollment < 400), lift("enrollment < 500", (r) => r.enrollment < 500),
lift("utilization < 50% AND enrollment < 400", (r) => r.util_headcount < 50 && r.enrollment < 400),
],
flagged_schools: liftUniverse.filter((r) => r.flagged).sort((x, y) => x.util_headcount - y.util_headcount).map((r) => ({ school_number: r.school_number, name: r.name, level: r.level, enrollment_2024_25: r.enrollment, enrollment_2025_26: get(r.school_number, LATEST, "enrollment_oct"), permanent_capacity: r.permanent, utilization_2024_25: r1(r.util_headcount), actions: r.actions })),
// How the flagged schools sit relative to the unflagged, within utilization deciles — the "where does risk jump" view.
by_utilization_bin: [0, 33.33, 50, 66.67, 80, 1e9].slice(0, -1).map((lo, i) => { const hi = [33.33, 50, 66.67, 80, 1e9][i]; const rr = liftUniverse.filter((r) => r.util_headcount >= lo && r.util_headcount < hi); return { lo: r1(lo), hi: hi > 1000 ? null : r1(hi), n: rr.length, flagged: rr.filter((r) => r.flagged).length, p: rr.length ? r3(rr.filter((r) => r.flagged).length / rr.length) : null }; }),
};
void base;
// 6. Per-pupil expenditure vs size (latest PPE year), district-run regular
const ppeYear = "2024-25";
const ppeRows = utilRows.map((r) => ({ ...r, ppe: get(r.school_number, ppeYear, "per_pupil_expenditure_school"), ppe_total: get(r.school_number, ppeYear, "per_pupil_expenditure_total") })).filter((r) => r.ppe !== null) as (U & { ppe: number; ppe_total: number | null })[];
const sizeBins: [number, number, string][] = [[0, 300, "< 300"], [300, 500, "300–499"], [500, 750, "500–749"], [750, 1000, "750–999"], [1000, 1e9, "1,000+"]];
const ppe_vs_size = {
year: ppeYear, n: ppeRows.length,
by_level: Object.fromEntries(["Elementary", "K-8", "Middle", "High"].map((lv) => { const rr = ppeRows.filter((r) => r.level === lv); return [lv, { n: rr.length, median_ppe: rr.length ? Math.round(median(rr.map((r) => r.ppe))) : null, spearman_log_enrollment: rr.length > 5 ? r3(spearman(rr.map((r) => [Math.log(r.enrollment), r.ppe]))) : null, by_size: sizeBins.map(([lo, hi, label]) => { const b = rr.filter((r) => r.enrollment >= lo && r.enrollment < hi); return { size: label, n: b.length, median_ppe: b.length ? Math.round(median(b.map((r) => r.ppe))) : null, median_enrollment: b.length ? Math.round(median(b.map((r) => r.enrollment))) : null }; }) }]; })),
// Excess cost of small elementary schools: (school PPE − median PPE of 500–749 ES) × enrollment, summed over ES < 300.
excess_cost_small_es: (() => { const es = ppeRows.filter((r) => r.level === "Elementary"); const ref = es.filter((r) => r.enrollment >= 500 && r.enrollment < 750); const refMed = ref.length ? median(ref.map((r) => r.ppe)) : NaN; const small = es.filter((r) => r.enrollment < 300); const students = sum(small.map((r) => r.enrollment)); const excess = Math.round(sum(small.map((r) => (r.ppe - refMed) * r.enrollment))); const medSmall = small.length ? Math.round(median(small.map((r) => r.ppe))) : null;
// excess_usd sums each small school's spending above the reference median times its own students (a per-school sum);
// median_gap_times_students_usd is the simpler reading (median small PPE − reference median) × students; the two differ
// because spending is right-skewed in the smallest schools (review 2026-09-16b, N4).
return { reference_median_ppe: Math.round(refMed), reference_n: ref.length, n_small: small.length, students_in_small: students, excess_usd: excess, excess_per_student_usd: students ? Math.round(excess / students) : null, median_ppe_small: medSmall, median_gap_usd: medSmall !== null ? medSmall - Math.round(refMed) : null, median_gap_times_students_usd: medSmall !== null ? (medSmall - Math.round(refMed)) * students : null }; })(),
scatter: ppeRows.filter((r) => ["Elementary", "K-8", "Middle", "High"].includes(r.level)).map((r) => ({ school_number: r.school_number, level: r.level, enrollment: r.enrollment, ppe: Math.round(r.ppe), flagged: flaggedBy.has(r.school_number) })),
};
// 7. Staffing (CCD 2023-24)
const staffRows = schools.filter((s) => sector(s) === "district_run_regular").map((s) => ({ level: s.school_level ?? "Other", enrollment: get(s.school_number, "2023-24", "enrollment_oct"), spt: get(s.school_number, "2023-24", "students_per_teacher") })).filter((r) => r.enrollment !== null && r.spt !== null) as { level: string; enrollment: number; spt: number }[];
const staffing = { year: "2023-24", n: staffRows.length, by_size: sizeBins.map(([lo, hi, label]) => { const b = staffRows.filter((r) => r.enrollment >= lo && r.enrollment < hi); return { size: label, n: b.length, median_students_per_teacher: b.length ? r1(median(b.map((r) => r.spt))) : null }; }) };
// 8. Events timeline
const events_timeline = events.map((e) => ({ ...e, name: byNum.get(e.school_number)?.name ?? e.school_number, level: byNum.get(e.school_number)?.school_level ?? null }));
// 9. Universe + crosschecks + meta
const universe = {
directory_records: schools.length, active: schools.filter((s) => s.activity_code === "A").length,
by_sector_latest: Object.fromEntries(["district_run_regular", "charter", "other"].map((k) => [k, schools.filter((s) => sector(s) === k && get(s.school_number, LATEST, "enrollment_oct") !== null).length])),
with_capacity_fish_year: utilRows.length,
};
const packet = {
meta: {
generated_at: new Date().toISOString(), years: YEARS, latest_year: LATEST, pre_pandemic_year: PRE_PANDEMIC, first_year: FIRST, fish_year: FISH_YEAR,
universe_note: "district-run regular = MSID primary service type K-12 general education, not a charter, any activity code; charter = MSID charter status R/C/T/B/H; other = alternative, ESE centers, virtual, DJJ, adult and technical colleges.",
sources: loads.map((l) => ({ source_id: l.source_id, sha256: l.sha256, year_covered: l.year_covered })),
qa_verdict: qa.verdict, qa_report: "docs/qa_reports/miami/__enrollment_full_qa.json",
},
universe, district_trend, grade_pipeline, pipeline_ratios, school_change, utilization, lift_table, ppe_vs_size, staffing, events_timeline,
};
fs.writeFileSync(OUT, JSON.stringify(packet, null, 1));
console.log(`[packet] wrote ${OUT}`);
console.log(` trend ${FIRST}: ${district_trend[FIRST].total.students} → ${LATEST}: ${district_trend[LATEST].total.students}; district-run ${district_trend[FIRST].district_run_regular.students} → ${district_trend[LATEST].district_run_regular.students}; charter ${district_trend[FIRST].charter.students} → ${district_trend[LATEST].charter.students}`);
console.log(` pre-pandemic→latest continuing district-run: n=${school_change.pre_pandemic_to_latest.n} ${school_change.pre_pandemic_to_latest.pct}% (median school ${school_change.pre_pandemic_to_latest.school_pct.p50}%)`);
console.log(` utilization ${FISH_YEAR}: n=${utilization.n} median ${utilization.headcount.p50}% below50=${utilization.thresholds_headcount.below_50} below33=${utilization.thresholds_headcount.below_33} sum/sum=${utilization.district_sum_headcount_pct}%`);
for (const c of lift_table.cuts) console.log(` lift ${c.cut}: ${c.flagged_below}/${c.n_below} vs ${c.flagged_at_or_above}/${c.n_at_or_above} lift=${c.lift} p=${c.fisher_p.toExponential(2)}`);
console.log(` PPE excess (ES<300): ${JSON.stringify(ppe_vs_size.excess_cost_small_es)}`);
await pool.end();
}
main().catch((e) => { console.error(e); process.exit(1); });