Source document

scripts/loaders/philly/compute_percentiles.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.

// Compute comparison_group_percentile + citywide_percentile for every
// (school × year × metric × subgroup × population_cut) row in
// philly_school_year_metrics.
//
// citywide_percentile: rank within all default-included Philly schools for the
//   same (metric, year, subgroup, population_cut), expressed as a fraction in
//   [0, 1].
// comparison_group_percentile: rank within the school's stored peer group
//   (from philly_comparison_groups.methodology_notes JSON, k=DEFAULT_K), same
//   (year, metric, subgroup, population_cut). Schools without a peer group
//   get null.
//
// For metrics where lower is better (e.g., OSS rates), we invert so a high
// percentile always means "better outcomes."
//
// Mirrors scripts/loaders/compute-percentiles.ts (NYC). Same midrank
// algorithm + same minimum-5-peers floor for peer percentiles.
//
// Run:  npx tsx scripts/loaders/philly/compute_percentiles.ts

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 ??
  process.env.POSTGRES_URL)!;
let pool: pg.Pool;
function makePool() {
  const u = new URL(directUrl);
  // Honor `?sslmode=disable` (local/dev Postgres has no TLS); otherwise keep
  // the permissive TLS Supabase needs.
  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,
    connectionTimeoutMillis: 15_000,
    idleTimeoutMillis: 10_000,
    statement_timeout: 60_000,
  });
}
pool = makePool();

async function query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]> {
  let attempts = 0;
  while (true) {
    try {
      const r = await pool.query(sql, params);
      return r.rows as T[];
    } catch (e) {
      attempts++;
      if (attempts >= 4) throw e;
      const msg = (e as Error).message;
      console.warn(`  query err (att ${attempts}): ${msg.slice(0, 80)} — reconnecting`);
      try { await pool.end(); } catch { /* ignore */ }
      pool = makePool();
      await new Promise((r) => setTimeout(r, 2000 * attempts));
    }
  }
}

function rank<T>(items: T[], valueOf: (t: T) => number): Map<T, number> {
  const sorted = items
    .map((t, i) => ({ t, v: valueOf(t), i }))
    .sort((a, b) => a.v - b.v);
  const n = sorted.length;
  const out = new Map<T, number>();
  let i = 0;
  while (i < n) {
    let j = i;
    while (j + 1 < n && sorted[j + 1].v === sorted[i].v) j++;
    const rankMid = (i + j) / 2 + 1; // 1-based midrank
    const pct = n === 1 ? 0.5 : (rankMid - 1) / (n - 1);
    for (let k = i; k <= j; k++) out.set(sorted[k].t, pct);
    i = j + 1;
  }
  return out;
}

async function main() {
  // Get each school's peer group (default k linked into philly_schools).
  const schoolRows = await query<{
    ulcs_code: string;
    methodology_notes: { peers: string[] } | null;
    include_in_default_comparisons: boolean;
  }>(
    `SELECT s.ulcs_code,
            cg.methodology_notes::jsonb AS methodology_notes,
            s.include_in_default_comparisons
       FROM philly_schools s
       LEFT JOIN philly_comparison_groups cg ON cg.id = s.comparison_group_id`,
  );
  const peerMap = new Map<string, Set<string>>();
  const includedSchools = new Set<string>();
  for (const s of schoolRows) {
    if (s.include_in_default_comparisons) includedSchools.add(s.ulcs_code);
    if (s.methodology_notes && Array.isArray(s.methodology_notes.peers)) {
      peerMap.set(s.ulcs_code, new Set([s.ulcs_code, ...s.methodology_notes.peers]));
    }
  }
  console.log(`Schools with peer groups: ${peerMap.size}`);
  console.log(`Schools in default comparisons: ${includedSchools.size}`);

  const combos = await query<{ metric_key: string; year: string; subgroup: string; population_cut: string }>(
    `SELECT DISTINCT metric_key, year, subgroup::text AS subgroup, population_cut
       FROM philly_school_year_metrics
      WHERE value IS NOT NULL AND suppressed = false`,
  );
  console.log(`(metric, year, subgroup, population_cut) combos: ${combos.length}`);

  const metricsList = await query<{ key: string; direction: string }>(
    `SELECT key, direction FROM philly_metric_definitions`,
  );
  const directionByKey = new Map(metricsList.map((m) => [m.key, m.direction]));

  let updated = 0;
  let comboIdx = 0;
  for (const { metric_key, year, subgroup, population_cut } of combos) {
    comboIdx++;
    const rows = await query<{ id: string; school_ulcs: string; value: number }>(
      `SELECT id, school_ulcs, value::float8 AS value
         FROM philly_school_year_metrics
        WHERE metric_key = $1 AND year = $2 AND subgroup::text = $3
          AND population_cut = $4
          AND value IS NOT NULL AND suppressed = false`,
      [metric_key, year, subgroup, population_cut],
    );
    if (rows.length === 0) continue;

    const direction = directionByKey.get(metric_key) ?? "HIGHER_BETTER";
    const sign = direction === "LOWER_BETTER" ? -1 : 1;

    // Citywide rank (only default-included schools)
    const citywideRows = rows.filter((r) => includedSchools.has(r.school_ulcs));
    const cityRanks = rank(citywideRows, (r) => sign * r.value);

    const byUlcs = new Map(rows.map((r) => [r.school_ulcs, r]));
    const updatesById: Array<{ id: string; cg: number | null; city: number | null }> = [];
    for (const r of rows) {
      const peers = peerMap.get(r.school_ulcs);
      let cg: number | null = null;
      if (peers) {
        const inPeers = [...peers]
          .map((u) => byUlcs.get(u))
          .filter((x): x is { id: string; school_ulcs: string; value: number } => !!x);
        if (inPeers.length >= 5) {
          const ranks = rank(inPeers, (x) => sign * x.value);
          const me = inPeers.find((p) => p.school_ulcs === r.school_ulcs);
          if (me) cg = ranks.get(me) ?? null;
        }
      }
      const city = cityRanks.get(r) ?? null;
      updatesById.push({ id: r.id, cg, city });
    }

    const batchSize = 300;
    for (let i = 0; i < updatesById.length; i += batchSize) {
      const batch = updatesById.slice(i, i + batchSize);
      const ids = batch.map((u) => u.id);
      const cgCases = batch
        .map((u) => `WHEN '${u.id}' THEN ${u.cg === null ? "NULL" : `${u.cg}::float8`}`)
        .join(" ");
      const cityCases = batch
        .map((u) => `WHEN '${u.id}' THEN ${u.city === null ? "NULL" : `${u.city}::float8`}`)
        .join(" ");
      const sql = `
        UPDATE philly_school_year_metrics
        SET comparison_group_percentile = (CASE id ${cgCases} END)::float8,
            citywide_percentile = (CASE id ${cityCases} END)::float8
        WHERE id = ANY($1::text[])
      `;
      await query(sql, [ids]);
      updated += batch.length;
    }
    if (comboIdx % 100 === 0) {
      console.log(`  ${comboIdx}/${combos.length} combos done, ${updated.toLocaleString()} rows updated`);
    }
  }

  console.log(`Updated percentiles on ${updated.toLocaleString()} rows`);
  await pool.end();
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});