// Load FLDOE Membership by School by Grade by Race → pct_black, pct_hispanic, // pct_white, pct_asian, pct_two_or_more, pct_amer_indian, pct_hawaiian_pac_isl // as shares of the same year's October enrollment (enrollment_oct). // // The file has no total column and hides cells under 10 students ('*', or // '**' in 2018-19 and 2021-22). Blank cells are grades not served (zero). // The school's unallocated count U = enrollment_oct − visible cells is // exact, so the question is only how to spread U over the hidden cells. // Rules, chosen by measuring against the unsuppressed NCES CCD copy // (docs/cities/miami/probe/race_imputation.py and race_imputation_prior.py; // findings F.15): // 1. hidden cells in a grade whose by-grade total is already fully visible // are zeros; // 2. in a year whose tokens can only hide students (no school has more // hidden cells S than hidden students U), U is split equally over the // remaining hidden cells — mean error vs CCD 0.8–0.9 pp on the Black and // Hispanic shares in 2018-19 and 2023-24; // 3. in a year where some school has S > U — the 2022-23 file marks zeros // with '*' too (257 such schools) — U is split over the hidden groups in // proportion to the school's mean share in the adjacent years that pass // rule 2 (their rule-2 shares), so groups the school never has get // nothing, and no group receives more than 9 students per hidden cell // (the publisher's own floor); the overflow is re-spread over the other // hidden groups by prior, then equally. Measured 2022-23 error vs CCD: // Black 0.89 pp (equal split 2.39, uncapped prior 1.21), Hispanic 0.66 // (2.15), White 0.84 (1.73), American Indian 0.08 (1.64); every school- // year inside the process bound; the small-group medians return to // 0.000 as in every other year. Applying the prior in rule-2 years was // measured worse (Black 1.10 vs 0.91) and is not done. Schools with no // adjacent rule-2 year keep the equal split. // The rule was chosen among five candidates by its mean error against the // CCD 2022-23 race subtotals (docs/cities/miami/probe/race_imputation_prior.py: // Black 2.39 → 0.89 pp, Hispanic 2.15 → 0.66, White 1.73 → 0.84), the same // reference the harness reports it against, so that number is in-sample. // The probe's split-half check (2026-09-16b) chooses the same rule on either // half of the schools and its held-out error equals the in-sample error of // the other half (0.78 / 0.83 pp), so the selection optimism is negligible. // Schools whose October total is itself suppressed get suppressed shares. // The harness re-measures the CCD agreement every run // (pipeline_miami/verify/cross_publisher_race.py). // Run: npx tsx scripts/loaders/miami/race_membership.ts import fs from "node:fs"; import { prisma, requireSource, startDataLoad, finishDataLoad, padSchoolNumber, parseCell, schoolUniverse, upsertFacts, sheetRows, runMain, SOURCES_ROOT, type Fact, type Provenance } from "./_lib"; const SCRIPT_PATH = "scripts/loaders/miami/race_membership.ts"; const norm = (s: unknown) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); const RACE_KEYS: [RegExp, string][] = [ [/^white/, "pct_white"], [/^black/, "pct_black"], [/^hispanic/, "pct_hispanic"], [/^asian/, "pct_asian"], [/^native hawaiian/, "pct_hawaiian_pac_isl"], [/^american indian/, "pct_amer_indian"], [/^two or more/, "pct_two_or_more"], ]; const KEYS = RACE_KEYS.map(([, k]) => k); /** Grade labels differ by file: "KG", "PK", "01".."12" or "1".."12". */ function normGrade(g: string): string { const t = g.trim().toUpperCase(); if (t === "PK" || t === "KG") return t; return /^\d+$/.test(t) ? String(Number(t)) : t; } function quantile(xs: number[], q: number): number { if (!xs.length) return NaN; const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(q * s.length))]; } type Acc = { visible: Record; hidden: Record; hiddenZero: number; hiddenCells: number; cells: number }; type SchoolYear = { total: number; U: number; S: number; acc: Acc; equal: Record }; // equal = rule-2 shares (0–100) type YearData = { sourceId: string; year: string; filePath: string; prov: Provenance; dade: number; sums: Map; enr: Map; cells: Map; noTotal: string[]; zerosMarked: number }; async function parseYear(sourceId: string, universe: Set): Promise { const year = sourceId.slice(-7); const { filePath, prov } = requireSource(sourceId); const { rows } = sheetRows(filePath, "School"); const h = rows.findIndex((r) => norm(r?.[0]) === "district"); if (h < 0) { console.warn(`[race ${year}] header not found; skipped`); return null; } const header = rows[h].map(norm); const iSch = header.indexOf("school"); const cols = RACE_KEYS.map(([re, key]) => ({ key, i: header.findIndex((c) => re.test(c)) })).filter((c) => c.i >= 0); if (cols.length !== RACE_KEYS.length) { console.warn(`[race ${year}] race columns not found in ${JSON.stringify(header)}; skipped`); return null; } const iGrade = header.indexOf("grade"); const enrRows = await prisma.miamiSchoolYearMetric.findMany({ where: { year, subgroup: "ALL", suppressed: false, OR: [{ metricKey: "enrollment_oct" }, { metricKey: { startsWith: "enrollment_oct_g" } }] }, select: { schoolNumber: true, metricKey: true, value: true } }); const enr = new Map(); const enrGrade = new Map(); for (const r of enrRows) { if (r.metricKey === "enrollment_oct") enr.set(r.schoolNumber, r.value ?? 0); else enrGrade.set(`${r.schoolNumber}|${normGrade(r.metricKey.slice("enrollment_oct_g".length))}`, r.value ?? 0); } const sums = new Map(); let dade = 0; for (const r of rows.slice(h + 1)) { if (String(r[0] ?? "").trim().replace(/\.0$/, "") !== "13") continue; dade++; const num = padSchoolNumber(r[iSch]); if (!num || !universe.has(num)) continue; const s = sums.get(num) ?? { visible: {}, hidden: {}, hiddenZero: 0, hiddenCells: 0, cells: 0 }; // Per grade: visible sum and the hidden cells; if the grade's published total equals the visible sum, the hidden cells are zeros (rule 1). let vis = 0; const hiddenHere: string[] = []; for (const c of cols) { const raw = String(r[c.i] ?? "").trim(); s.cells++; if (raw === "") continue; // grade not served → zero const p = parseCell(raw); if (p.suppressed) hiddenHere.push(c.key); else { s.visible[c.key] = (s.visible[c.key] ?? 0) + (p.value ?? 0); vis += p.value ?? 0; } } const gTotal = iGrade >= 0 ? enrGrade.get(`${num}|${normGrade(String(r[iGrade] ?? ""))}`) : undefined; const gradeFullyVisible = gTotal !== undefined && Math.abs(gTotal - vis) < 0.5; for (const k of hiddenHere) { if (gradeFullyVisible) s.hiddenZero++; else { s.hidden[k] = (s.hidden[k] ?? 0) + 1; s.hiddenCells++; } } sums.set(num, s); } // Rule-2 (equal split) shares and the token diagnostic per school. const cells = new Map(); const noTotal: string[] = []; let zerosMarked = 0; for (const [num, acc] of sums) { const total = enr.get(num); if (!total) { noTotal.push(num); continue; } const numeric = Object.values(acc.visible).reduce((a, b) => a + b, 0); const U = Math.max(0, total - numeric); const S = acc.hiddenCells; if (S > U) zerosMarked++; const perCell = S > 0 ? U / S : 0; const equal: Record = {}; for (const k of KEYS) equal[k] = (((acc.visible[k] ?? 0) + perCell * (acc.hidden[k] ?? 0)) / total) * 100; cells.set(num, { total, U, S, acc, equal }); } return { sourceId, year, filePath, prov, dade, sums, enr, cells, noTotal, zerosMarked }; } async function main() { const dirs = fs.readdirSync(SOURCES_ROOT).filter((d) => /^fldoe_membership_by_school_grade_race_\d{4}-\d{2}$/.test(d)).sort(); const universe = await schoolUniverse(); const years: YearData[] = []; for (const sourceId of dirs) { const y = await parseYear(sourceId, universe); if (y) years.push(y); } for (let i = 0; i < years.length; i++) { const yd = years[i]; const trustworthy = yd.zerosMarked === 0; // Rule 3 prior: the loaded years one CALENDAR year either side (not the list neighbours, so a year that failed to // parse cannot make a non-adjacent year the prior) whose tokens hide students only. const spring = (y: string) => parseInt(y.slice(0, 4), 10); const adjacent = years.filter((a) => Math.abs(spring(a.year) - spring(yd.year)) === 1 && a.zerosMarked === 0); const facts: Fact[] = []; const unallocated: number[] = []; let hiddenZeroTotal = 0, hiddenCellsTotal = 0, priorUsed = 0, priorFallback = 0, capViolations = 0; for (const [num, c] of yd.cells) { unallocated.push((c.U / c.total) * 100); hiddenZeroTotal += c.acc.hiddenZero; hiddenCellsTotal += c.S; let share = c.equal; if (!trustworthy && c.S > 0 && c.U > 0) { const priors = adjacent.map((a) => a.cells.get(num)?.equal).filter((p): p is Record => !!p); const hiddenKeys = KEYS.filter((k) => (c.acc.hidden[k] ?? 0) > 0); const w: Record = {}; for (const k of hiddenKeys) w[k] = priors.length ? priors.reduce((s, p) => s + p[k], 0) / priors.length : NaN; const wSum = hiddenKeys.reduce((s, k) => s + (Number.isFinite(w[k]) ? w[k] : 0), 0); if (priors.length && wSum > 0) { // Prior-proportional allocation, capped at 9 students per hidden cell; overflow re-spread by prior, then equally. const alloc: Record = Object.fromEntries(hiddenKeys.map((k) => [k, 0])); const limit: Record = Object.fromEntries(hiddenKeys.map((k) => [k, 9 * (c.acc.hidden[k] ?? 0)])); let remaining = c.U; let active = hiddenKeys.filter((k) => w[k] > 0); for (let iter = 0; iter <= hiddenKeys.length && remaining > 1e-9 && active.length; iter++) { const aSum = active.reduce((s, k) => s + w[k], 0); for (const k of active) { const take = Math.min(remaining * w[k] / aSum, limit[k] - alloc[k]); alloc[k] += take; } remaining = c.U - hiddenKeys.reduce((s, k) => s + alloc[k], 0); active = active.filter((k) => alloc[k] < limit[k] - 1e-9); } // Overflow (every prior>0 cell at its cap): to the still-uncapped hidden cells — those with a zero prior — by // cell count, iterating against the cap; only if every hidden cell is capped (U > 9 × S, a file inconsistency) // does the rest go over all hidden cells by cell count, and that case is counted. for (let iter = 0; iter <= hiddenKeys.length && remaining > 1e-9; iter++) { const open = hiddenKeys.filter((k) => alloc[k] < limit[k] - 1e-9); if (!open.length) break; const hs = open.reduce((s, k) => s + (c.acc.hidden[k] ?? 0), 0); for (const k of open) { const take = Math.min(remaining * (c.acc.hidden[k] ?? 0) / hs, limit[k] - alloc[k]); alloc[k] += take; } remaining = c.U - hiddenKeys.reduce((s, k) => s + alloc[k], 0); } if (remaining > 1e-9) { capViolations++; const hs = hiddenKeys.reduce((s, k) => s + (c.acc.hidden[k] ?? 0), 0); for (const k of hiddenKeys) alloc[k] += remaining * (c.acc.hidden[k] ?? 0) / hs; } share = {}; for (const k of KEYS) share[k] = (((c.acc.visible[k] ?? 0) + (alloc[k] ?? 0)) / c.total) * 100; priorUsed++; } else priorFallback++; } for (const k of KEYS) facts.push({ schoolNumber: num, year: yd.year, metricKey: k, value: Math.min(100, share[k]), denominator: c.total }); } // Schools whose October total is suppressed: shares cannot be computed; serve them suppressed (never a stale CCD-derived value). for (const num of yd.noTotal) for (const k of KEYS) facts.push({ schoolNumber: num, year: yd.year, metricKey: k, value: null, suppressed: true }); const rule = trustworthy ? "equal split of U over hidden cells (no school has more hidden cells than hidden students)" : `adjacent-year prior capped at 9 per hidden cell (${adjacent.map((a) => a.year).join(", ") || "none available"}) — ${yd.zerosMarked} schools have more hidden cells than hidden students, so '*' marks zeros too; prior applied to ${priorUsed} schools, equal-split fallback ${priorFallback}, schools whose unallocated count exceeds 9 × hidden cells (cap unavoidable) ${capViolations}`; const note = `Dade rows ${yd.dade}; schools ${yd.sums.size}; without same-year enrollment_oct (served suppressed) ${yd.noTotal.length}; hidden cells ${hiddenCellsTotal} (+${hiddenZeroTotal} resolved as zeros from grade totals) of ${[...yd.sums.values()].reduce((a, s) => a + s.cells, 0)}; unallocated share pp median ${quantile(unallocated, 0.5).toFixed(2)} p90 ${quantile(unallocated, 0.9).toFixed(2)} max ${quantile(unallocated, 1).toFixed(2)}; rule: ${rule}`; const load = await startDataLoad({ sourceId: yd.sourceId, sourceUrl: yd.prov.url, sha256: yd.prov.sha256, bytes: yd.prov.bytes, yearCovered: yd.year, scriptPath: SCRIPT_PATH, notes: note }); const res = await upsertFacts(facts, load.id); await finishDataLoad(load.id, { inserted: res.written, updated: 0, suppressed: res.suppressed }); console.log(`[race ${yd.year}] ${res.written} facts; ${note}`); } } runMain(main);