05 — Data Validation Spec (correctness vs. reality)
Implements the "correctness vs reality" gap (
02_gap_analysis.md#9), the verification spine in03_integration_plan.md §C3, and the read-only Python approach agreed in review. This is the original design; the as-built harness is in PR B (verify/), withverify/METHODOLOGY.mdauthoritative wherever the two differ (notably the independent reference — see below).Validation taxonomy (standardized). (i) completeness (missing years/schools); (ii) correctness — base case (DB vs the exact ingested source file); (iii) correctness — spot check (DB vs a public-facing per-school report); (iv) correctness — computed values (derived metrics). This spec was originally framed as "Phase 1a/1b/2", which map to: 1a → (ii) base case (ships first); 1b → an independent-publisher correctness check (deferred); 2 → (iv) computed (follow-up). The shipped (iii) spot check uses the NYC School Snapshot (same-publisher presentation fidelity), not the NYSED check planned here. Pilot one metric end-to-end, then templatize.
Prototype =
mwg1378/nyc-edu-data(cloned atmain @ a4baa1a). Scaffold reused =reference-scaffold/(pipeline/verify/).
0. Principles (load-bearing)
- Read-only. The validator never writes to the prototype DB or to upstream sources. It connects to Postgres via a read-only role, issues SELECT only, imports no loader code, and writes only to its own output dirs (the reference store + QA reports). See §5.4.
- Independent. We re-read the source files and the official figures ourselves and compare them to what the site serves. Verification does not depend on, or need to understand, the prototype's calculation code. (For (iv) computed metrics we independently re-derive the documented formula — still not their implementation.)
- Replicable + provenanced. Every file we fetch is stored in full with
{url, retrieved_at, sha256, bytes, http_status, publisher}so any result can be reproduced from the exact bytes, offline. See §2. - Deterministic. Same inputs → same report; content hashes recorded; fixed sampling seed.
- Suppression-aware. Officially-suppressed cells are reported as
n/a, never as a mismatch.
1. Phasing
The correctness work is split so the first deliverable is self-contained and squarely our responsibility: the (ii) base case ships first; an independent-publisher correctness check is deferred; (iv) computed values is the final follow-up.
| (ii) Base case — internal fidelity (ships first) | Independent-publisher correctness (deferred) | (iv) Computed values (follow-up) | |
|---|---|---|---|
| Question | "Does the site faithfully serve the stated source file it ingests?" | "Is that source file itself right, vs an independent publisher?" | "Are the computed metrics derived correctly from validated components?" |
| Targets | Each directly-ingested component metric (per-grade proficiency, grad rate, chronic absenteeism, per-question survey %, SSEC counts) | Same components | Composites (ES/MS academic, teacher-voice), survey rollups, course-access index |
| Layers compared | served (DB) ↔ independent re-read of the ingested source file | + independent reference (different publisher: NYSED) → independent truth | re-derive documented formula from validated components → formula fidelity (no external layer) |
| Reference needed | None beyond the file we already ingest (downloaded + provenanced) | A different-publisher per-school file (NYSED); NYC Open Data shares the NYC publisher | n/a |
| Catches | parse/ingest bugs: column mapping, units, year norm, suppression, dropped/dup rows, DBN mismatch | source-level errors faithfully copied from a wrong file | bad composite membership / rollup question set / index formula |
| Does not catch | whether the source file itself is wrong (→ independent check) | — | — |
| Depends on | a read-only DB connection; runs against the current DB today | base case green + (for NYSED) DBN↔BEDS crosswalk | base-case components passing |
| Course access (#8) | n/a (derived) | n/a (NYCDOE publishes no index, spec §9.7) | Input-parse validation only |
This spec details the (ii) base case fully (the shipping piece). As built (PR B): the shipped (iii) spot check compares the DB against the NYC School Snapshot (same-publisher presentation fidelity, §5.3) rather than the different-publisher NYSED check planned in the middle column — that independent check is deferred. (iv) computed values is outlined in §9.
Ownership note: the base case ("we faithfully use our stated source") is our responsibility; discrepancies between NYC and NYSED are largely NY State's data-quality concern, which is why the independent-publisher check is a separate, later step.
2. Source acquisition & provenance
Two distinct source roles, both downloaded in full (never sampled) and provenanced:
- Ingested source — the exact file the prototype pulls (e.g. NYCDOE InfoHub ELA/Math xlsx,
scripts/loaders/test-results.ts:12-23). Used for parse fidelity (did their ingest faithfully reflect this file?). - Independent reference — an authoritative per-school figure from, where possible, a different publisher than the ingested source, so the check catches source-level problems, not just parse bugs (
pipeline/verify/references/base.py:9-16). Used for external truth.
2.1 Reference store layout (read-only artifact, committed metadata)
data/reference/
sources/
<source_id>/<YYYY-MM-DD>/ # one dir per fetch
<original_filename> # the full file, verbatim
provenance.json # see schema below
manual/<metric>.csv # optional human-recorded spot values (manual_csv adapter)
provenance.json schema (one per fetched file):
{
"source_id": "nysed_3to8_assessment",
"role": "reference", // "ingested" | "reference"
"publisher": "NYSED",
"url": "https://data.nysed.gov/...", // exact download URL
"retrieved_at": "2026-06-01T14:03:22Z",
"http_status": 200,
"sha256": "…",
"bytes": 12345678,
"format": "xlsx",
"metrics_backed": ["g3_ela_proficiency", "g3_math_proficiency"],
"notes": "Per-school 3-8 assessment DB; keyed by 12-digit BEDS."
}
Large source files are git-ignored (mirroring the prototype's
.gitignore:32-37); onlyprovenance.json+ the registry below are committed, so the record is in version control while the bytes live in the (reproducible) reference store. A small downloader recordsprovenance.jsonand verifies the sha256 on re-fetch.
2.2 Source registry — verify/sources.yaml
Human-and-machine-readable catalog driving replication. One entry per source file:
- source_id: nycdoe_infohub_ela
role: ingested
publisher: NYCDOE InfoHub
url: https://infohub.nyced.org/.../school-ela-results-2018-2025-public....xlsx
format: xlsx
sheets: ["ELA - All", "ELA - SWD", ...] # which sheets carry per-school rows
key_columns: {dbn: DBN, year: Year, grade: Grade, value: "% Level 3+4", n: "Number Tested"}
join_key: DBN
refresh: annual
metrics_backed: [g3_ela_proficiency, g7_ela_proficiency, ...]
- source_id: nysed_3to8_assessment
role: reference
publisher: NYSED
url: https://data.nysed.gov/... # exact URL recorded at fetch time
format: xlsx
join_key: BEDS # -> DBN via crosswalk (§3.3)
metrics_backed: [g3_ela_proficiency, g3_math_proficiency, ...]
(Exact reference URLs are filled in at first fetch and pinned in provenance.json;
this spec names the publisher/role, not fabricated URLs.)
3. Validation method
As built (PR B): the shipped harness runs two checks — (ii) base case (served vs an independent re-read of the ingested file) and (iii) spot check (served vs the NYC School Snapshot). The three-layer reference model below (an independent-publisher
reference_valueplus thesource_consistencydiagnostic) is the original design; the different-publisher (NYSED) layer is deferred.verify/METHODOLOGY.mdis authoritative for the as-built design.
Three independently-read layers, per (dbn, year, metric_key, subgroup=ALL):
| Layer | How obtained | Connector |
|---|---|---|
source_value | re-read the ingested source file ourselves (independent parse) | source (§5.1) |
served_value | the value the site serves = read-only Postgres school_year_metric.value | served (§5.2) |
reference_value | the official per-school figure from the independent reference | reference adapter (§5.3) |
Three comparisons (tolerance tolerance_abs, per metric):
- parse_fidelity =
served_valuevssource_value→ catches the prototype's ingest/parse bugs. - external_truth =
served_valuevsreference_value→ the headline correctness check. - source_consistency =
source_valuevsreference_value→ diagnostic: ifserved == sourcebut both ≠reference, the source file disagrees with the official reference (publisher discrepancy), not a parse bug.
Status values: match | MISMATCH | suppressed | unavailable | n/a. We reuse the
scaffold's _status + summarize + write_report verbatim (reconcile.py:109-205);
only the layer-loaders are new (§5).
3.1 Suppression
A cell suppressed by the publisher (s/*/n<5, per _fetch.ts:111-115) yields
reference_value.status = "suppressed" and is excluded from agreement rates — never a mismatch.
3.2 Sampling & known cases
Stratify by borough + admission_bucket, fixed seed, configurable size
(reconcile.py:82-105). Always include a curated known-case list (a few schools whose
official figures we read by hand) as a face-validity anchor. (The scaffold also always
includes flagged outliers; in the base case there are no engine flags yet, so the sample =
stratified + known-cases.)
3.3 Join keys / crosswalk
NYC sources key on DBN; NYSED sources key on 12-digit BEDS. Reference adapters
reading NYSED data translate via the DBN↔BEDS crosswalk the prototype already loads
(scripts/loaders/dbn-beds-crosswalk.ts); cached_file supports a crosswalk
(references/base.py:14-16). Unmatched rows are enumerated in the report, not dropped.
4. Component → source → reference map
Per-school externally-checkable components behind each of the spec's 8 outcomes.
(metric_keys follow the prototype's convention, e.g. test-results.ts:157.)
| Spec outcome | Component metric(s) | Prototype metric_key | Ingested source | Independent reference (publisher) | Join | Notes |
|---|---|---|---|---|---|---|
| 1. 3rd-gr ELA+Math | g3 ELA %L3+4; g3 Math %L3+4 | ela_grade3_proficiency, math_grade3_proficiency | InfoHub ELA/Math xlsx | NYSED 3–8 Assessment DB | DBN / BEDS | pilot |
| 2. 7th-gr ELA+Math | g7 ELA/Math %L3+4 | *_grade7_proficiency | same xlsx | NYSED 3–8 Assessment DB | DBN/BEDS | reuses loader |
| 3. Graduation (+CCR) | 4-yr grad rate | graduation_rate_4yr | NYC Open Data mjm3-8dw8 | NYSED grad-rate DB | DBN/BEDS | InfoHub grad xlsx returns empty bodies → prototype falls back to Open Data (graduation-results.ts:2,16); cohort = 4-yr August |
| 3. CCR | CCR rate | (school-quality key) | InfoHub School Quality | NYC Open Data / School Quality | DBN | confirm which CCR index (spec §9.8) |
| 4. Bullying (incidents) | SSEC incident counts | (SSEC key) | NYSED SSEC | NYSED SSEC public file | BEDS | low-N, reporting-sensitive |
| 5. Chronic absenteeism | % chronically absent | (attendance key) | InfoHub attendance | NYSED report-card / NYC Open Data | DBN | per-year reconcile (COVID regime irrelevant here) |
| 4/6/7. Survey items | per-question positive % | SurveyResponse.positive_pct | NYC School Survey file | NYC School Survey published results | DBN | NYC-only publisher → independence limited; check at question grain |
| 8. Course access | (directory inputs) | — | HS Directory xlsx | none published | DBN | parse-validation only |
For the survey-derived rollups (#4 bullying, #6 trust, #7 recommend) the
externally-checkable unit is the underlying question's positive_pct, not the
rollup; the rollup itself is a (iv) computed check.
5. Architecture
Lands in the prototype repo as a new additive Python tree (the scaffold's
pipeline/verify/ adapted), with its own requirements.txt + CI lane — the
second-toolchain note in 04_contribution_plan.md §4 applies. Reuses the scaffold's
reconcile skeleton; only the connectors are new.
verify/
sources.yaml # source registry (§2.2)
components.yaml # per-component config (§5.5)
download.py # full-file fetch + provenance.json + sha256 verify (read-only upstream)
connectors/
served_db.py # read-only Postgres SELECT -> served_value
source_file.py # independent xlsx/csv re-read -> source_value
references/ # lifted from scaffold; each adapter records vintage
base.py manual_csv.py cached_file.py socrata.py
reconcile.py # scaffold skeleton: sample, compare(3 checks), summarize, report
checks.py # optional internal gates (coverage, join-match rate)
requirements.txt # numpy, pandas, pyarrow, PyYAML, pytest, psycopg[binary], openpyxl
5.1 source connector — independent re-read
Reads the ingested source file from the reference store (by provenance.json) using
pandas (read_excel/read_csv), maps key_columns from sources.yaml, normalizes
year/DBN, returns {(dbn, year): value}. Deliberately re-implements parsing
independently of test-results.ts — divergence is the signal.
5.2 served connector — what the site serves (READ-ONLY)
A read-only Postgres SELECT against school_year_metrics (+ schools for
borough/admission strata). Returns {(dbn, year, metric_key): {value, suppressed}}.
For the directly-ingested base metrics the served value is the displayed value (precomputed at
ingest, read straight through — lib/queries/schools.ts), so this faithfully
represents the site without scraping. (Display/formatting + the on-the-fly views
— movers delta, decile selection — are out of scope here; covered cheaply by TS
unit tests.)
5.3 reference adapters — official figure
Scaffold interface (references/base.py): get(dbn, year, metric_key) -> ReferenceValue{value,status,vintage}.
cached_file(primary) — an independent bulk official file (e.g. NYSED assessment/grad DB) stored in the reference store; offline, deterministic, different publisher; crosswalk-aware. Matches the user's "download full data files."manual_csv— human-recorded per-school values for known-cases / sources with no bulk file (survey item spot-checks).socrata— NYC Open Data API (DBN-native, automatable) where a suitable dataset exists; note independence caveat if it mirrors the ingested publisher.
5.4 Read-only enforcement
- Dedicated read-only DB role; connection via
VALIDATE_READONLY_DATABASE_URL(separate from the loaders'DIRECT_URLsuperuser). - Connector issues SELECT only; no
INSERT/UPDATE/DELETE; imports no module underscripts/loaders/. - All writes confined to
data/reference/**anddocs/qa_reports/**.
5.5 Per-component config — verify/components.yaml
Mirrors the scaffold reconciliation block (config/outcomes.yaml:39-50):
- metric_key: ela_grade3_proficiency
subgroup: ALL
ingested_source: nycdoe_infohub_ela # -> sources.yaml
reference_sources: [nysed_3to8_assessment] # cached_file
reference_metric_key: g3_ela_pct_level34
tolerance_abs: 1.0 # pp; absorbs cross-publisher rounding
sample: { size: 30, stratify_by: [borough, admission_bucket], seed: 12345 }
known_cases: ["01M015 2023-24", ...] # hand-checked anchors
6. Outputs
data/reference/sources/<source>/<date>/provenance.json— provenance for every fetched file.docs/qa_reports/<metric>__validation.{md,csv,json}— per-cell table (3 statuses) + summary (agreement rate, parse breaks, unmatched, suppressed) + mismatch list. (Format = scaffoldwrite_report,reconcile.py:179-204.)docs/validation_cards/<metric>.md— the replicable process card (template §8.1): source URLs, sheets/columns, reference, join, tolerance, suppression, gotchas, sign-off.
7. Acceptance criteria (per metric; gate a release under --strict)
- Provenance complete:
url+retrieved_at+sha256+bytesrecorded for every file used. - Join-match rate ≥ threshold; all unmatched rows enumerated (not silently dropped).
external_truthagreement rate reported; 0 external MISMATCH and 0 parse_fidelity breaks under--strict(exit nonzero otherwise —reconcile.py:220-228).- All known-case anchors checked and matching.
- Suppressed cells reported as
suppressed, notMISMATCH. - Validation card filled + signed off.
8. Pilot (one metric, end-to-end) → then replicate
Pilot = ela_grade3_proficiency (ALL subgroup). Flagship academic input, clean
per-school publication on both NYCDOE and NYSED, sets up the composite ((iv) computed) next.
End-to-end:
download.pyfetches the full InfoHub ELA xlsx (ingested) and the NYSED 3–8 assessment DB (reference); writes both +provenance.json(URL + date + sha256).- Implement
connectors/source_file.py(ELA xlsx → per-school %L3+4) andconnectors/served_db.py(read-only SELECT). - Wire
cached_filereference adapter + DBN↔BEDS crosswalk; recordknown_cases. - Run
reconcile.py --metric ela_grade3_proficiency; produce QA report + validation card. - Verify on a local branch (
kx/data-validation), then PR to mwg1378 for review.
Replication to the rest = add a sources.yaml + components.yaml block + (if needed) a
source reader per metric, reusing everything else. Order by source affinity:
g7 (reuses ELA/Math reader) → graduation → chronic absenteeism → survey items → SSEC.
#8 course access = parse-only.
8.1 Validation card template (docs/validation_cards/<metric>.md)
# Validation card: <metric_key>
- Ingested source: <source_id, url, retrieved_at, sha256, sheets/columns>
- Independent reference: <publisher, source_id, url, retrieved_at, sha256, join key>
- Tolerance: ±<tolerance_abs> (<unit>) | Suppression: <handling>
- Sample: size=<>, stratify=<>, seed=<> | Known cases: <list + official values>
- Results: parse_fidelity=<>, external_agreement=<>, unmatched=<>, suppressed=<>
- Mismatches investigated: <notes>
- Sign-off: <name, date, commit>
9. (iv) Computed values — derived-metric validation (follow-up)
For composites / survey rollups: re-derive the documented formula in Python from the base-case-validated components and compare to the stored derived value (formula fidelity), within tolerance. No external layer.
composite_es_academic= mean(ela_grade3_proficiency,math_grade3_proficiency) — confirm againstcompute-composites.ts:33definition, recompute independently.survey_*rollups = mean of matched-questionpositive_pct— confirm the question set + average againstcompute-survey-metrics.ts:25-99, recompute from validated question values.- Course access (#8): validate the directory inputs parse correctly; no external index exists. This phase also surfaces whether the prototype's derivation choices (which questions roll up, composite membership) are defensible — a methodology discussion for mwg1378, not just an arithmetic check.
10. Workflow
Per 04_contribution_plan.md: sync main → branch kx/data-validation → build
the (ii) base-case pilot read-only → verify locally → PR to mwg1378 (link this
spec + gap #9) → merge → replicate per metric (small PRs) → (iv) computed as a
separate branch.
11. To confirm with mwg1378 before coding
- Independent reference per metric — NYSED bulk file (stronger, BEDS+crosswalk) vs NYC Open Data/Socrata (DBN-native, but may mirror the ingested publisher). Per the map in §4.
- Read-only DB role provisioning (
VALIDATE_READONLY_DATABASE_URL). - Where the
verify/tree lives + adding the first CI lane for the Python test/gate. - Tolerances per metric family (rounding differences across publishers).
- Survey independence: accept question-grain check against the NYC-published survey file (no second publisher exists).