Source document

pipeline_philly/verify/reconcile.py

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.

"""Base case (ii) reconciliation: served DB vs exact ingested source file.

For each component in components.yaml, reads the served value from
philly_school_year_metrics, re-reads the same cell from the ingested
source file, and compares within tolerance.

Mirrors verify/reconcile.py (NYC). Writes a Markdown + JSON report per metric
to docs/qa_reports/philly/<metric_key>__validation.md (+ .json).

Run:
  python pipeline_philly/verify/reconcile.py --metric pssa_all_ela_proficiency
  python pipeline_philly/verify/reconcile.py --all
"""
from __future__ import annotations

import argparse
import json
import pathlib
import sys

# Make pipeline_philly importable when this script is run as a path.
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]))

import yaml  # noqa: E402

from pipeline_philly.verify import common  # noqa: E402
from pipeline_philly.verify.connectors import served_db  # noqa: E402
from pipeline_philly.verify.connectors import source_file  # noqa: E402


def load_components() -> list[dict]:
    with open(common.COMPONENTS_YAML) as f:
        return yaml.safe_load(f)


def read_source(component: dict) -> dict[tuple[str, str], dict]:
    """Dispatch a component to the right source-file reader."""
    sid = component["served_source"]
    args = component.get("source_args", {})
    subgroup = component["subgroup"]
    year = component.get("year")

    if sid == "sdp_pssa_keystone_2024-25":
        return source_file.read_sdp_pssa_zip_schools(
            cut=args["cut"],
            year=year,
            testname=args["testname"],
            subject=args["subject"],
            grade=args["grade"],
            subgroup=subgroup,
        )
    elif sid == "sdp_attendance_90_school":
        return source_file.read_sdp_csv_long(
            source_id=sid,
            metric_value_col=args["metric_value_col"],
            subgroup=subgroup,
        )
    elif sid == "sdp_graduation_school_2014-2025":
        return source_file.read_sdp_csv_long(
            source_id=sid,
            metric_value_col=args["metric_value_col"],
            school_col=args["school_col"],
            year_col=args["year_col"],
            group_col=args["group_col"],
            subgroup=subgroup,
            extra_filter=args.get("extra_filter"),
        )
    else:
        raise SystemExit(f"no source-reader dispatch for source: {sid}")


def reconcile_one(component: dict) -> dict:
    """Run base-case reconciliation for one metric. Returns summary dict."""
    metric = component["metric_key"]
    subgroup = component["subgroup"]
    cut = component["population_cut"]
    tol = float(component["tolerance_abs"])
    year_filter = component.get("year")

    served = served_db.fetch(metric, subgroup=subgroup, population_cut=cut)
    source = read_source(component)

    # If a single year was specified in the config, restrict served + source to it
    if year_filter:
        served = {k: v for k, v in served.items() if k[1] == year_filter}
        source = {k: v for k, v in source.items() if k[1] == year_filter}

    served_keys = set(served.keys())
    source_keys = set(source.keys())
    common_keys = served_keys & source_keys

    # Cell-level fidelity within common keys
    matches = 0
    mismatches = []
    suppressed = 0
    for k in common_keys:
        s_val = served[k]["value"]
        f_val = source[k]["value"]
        if served[k]["suppressed"]:
            suppressed += 1
            continue
        if s_val is None and f_val is None:
            matches += 1
            continue
        if s_val is None or f_val is None:
            mismatches.append((k, s_val, f_val, None))
            continue
        diff = s_val - f_val
        if abs(diff) <= tol:
            matches += 1
        else:
            mismatches.append((k, s_val, f_val, diff))

    return {
        "metric_key": metric,
        "subgroup": subgroup,
        "population_cut": cut,
        "tolerance_abs": tol,
        "n_served": len(served),
        "n_source": len(source),
        "n_common": len(common_keys),
        "matches": matches,
        "mismatches": mismatches,
        "suppressed": suppressed,
        "served_only_count": len(served_keys - source_keys),
        "source_only_count": len(source_keys - served_keys),
        "served_only_examples": sorted([f"{k[0]} {k[1]}" for k in (served_keys - source_keys)])[:20],
        "source_only_examples": sorted([f"{k[0]} {k[1]}" for k in (source_keys - served_keys)])[:20],
        "agreement_rate": matches / (matches + len(mismatches)) if (matches + len(mismatches)) else None,
        "status": "PASS" if matches and len(mismatches) == 0 else "MISMATCH",
    }


def render_report(summary: dict) -> str:
    out: list[str] = []
    out.append(f"# Validation (ii) base case — {summary['metric_key']}\n")
    out.append("served DB cell-by-cell vs an independent re-read of the exact ingested file.\n")
    out.append(f"## Summary\n")
    for k in [
        "metric_key", "subgroup", "population_cut", "tolerance_abs",
        "n_served", "n_source", "n_common", "matches", "suppressed",
        "served_only_count", "source_only_count", "agreement_rate", "status",
    ]:
        out.append(f"- {k}: {summary.get(k)}")
    if summary.get("served_only_examples"):
        out.append("\n## Served-only examples\n")
        for e in summary["served_only_examples"]:
            out.append(f"- {e}")
    if summary.get("source_only_examples"):
        out.append("\n## Source-only examples\n")
        for e in summary["source_only_examples"]:
            out.append(f"- {e}")
    if summary["mismatches"]:
        out.append(f"\n## Mismatches ({len(summary['mismatches'])})\n")
        for (key, s, f, d) in summary["mismatches"][:50]:
            out.append(f"- {key[0]} {key[1]}: served={s} source={f} Δ={d}")
        if len(summary["mismatches"]) > 50:
            out.append(f"- ... and {len(summary['mismatches']) - 50} more")
    return "\n".join(out)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--metric", default=None)
    ap.add_argument("--all", action="store_true")
    args = ap.parse_args()

    common.ensure_qa_dir()
    components = load_components()
    if args.metric:
        components = [c for c in components if c["metric_key"] == args.metric]
        if not components:
            print(f"no component matching --metric={args.metric}", file=sys.stderr)
            sys.exit(1)
    if not args.metric and not args.all:
        print("must pass --metric=<key> or --all")
        sys.exit(1)

    for component in components:
        summary = reconcile_one(component)
        # Persist the report
        slug = component["metric_key"]
        md = render_report(summary)
        (common.QA_REPORTS_DIR / f"{slug}__validation.md").write_text(md)
        json_summary = {k: v for k, v in summary.items() if k != "mismatches"}
        json_summary["n_mismatches"] = len(summary["mismatches"])
        (common.QA_REPORTS_DIR / f"{slug}__validation.json").write_text(
            json.dumps(json_summary, indent=2),
        )
        print(
            f"{slug:38s} STATUS: {summary['status']:9s}  "
            f"common={summary['n_common']:>5d}  matches={summary['matches']:>5d}  "
            f"mismatches={len(summary['mismatches']):>4d}  "
            f"served_only={summary['served_only_count']:>3d}  source_only={summary['source_only_count']:>3d}",
        )


if __name__ == "__main__":
    main()