02 — Gap Analysis: Prototype vs. Design Spec
Each dimension is marked Aligned / Diverges / Missing with a one-line "why it matters" and
file:linecitations. Spec =nyc-school-outcomes-spec.md. Scaffold (reference Python implementation) =reference-scaffold/. Prototype = this repository (the repo root).
Legend: 🟢 Aligned · 🟡 Diverges · 🔴 Missing
| # | Dimension | Verdict |
|---|---|---|
| 1 | Peer groups — match vs. adjust | 🟡 Diverges (matches, doesn't regress out) |
| 2 | Outlier definition — decile vs. residual-z | 🟡 Diverges (core gap) |
| 3 | Proclivity decile — control vs. display | 🟡 Diverges (display/filter only) |
| 4 | Trends / time — regime-aware z-slope | 🔴 Missing (raw deltas) |
| 5 | Comparability years (2020/21/22, NextGen) | 🔴 Missing in code (editorial only) |
| 6 | Suppression / small-N | 🟢 Aligned (suppression) / 🟡 (no reliability weighting) |
| 7 | Survey rollups — cross-year continuity | 🟡 Diverges (no continuity check) |
| 8 | Provenance & determinism | 🟡 Diverges (provenance partial; no golden/checksum) |
| 9 | Correctness vs reality (official per-school reconcile) | 🔴 Missing — the largest gap |
1. Peer groups — 🟡 Diverges
Why it matters: matching on demographics and regressing them out answer subtly different questions; the spec wants the outcome adjusted, not just the comparison set constrained.
- Prototype: KNN(K=40) with hard filters (grade band + admission bucket) + weighted Euclidean over 7 demographic shares (1.0) + topic Jaccard (0.5).
scripts/loaders/nycenet-comparison-groups.ts:24-95. - The peer group only constrains who you're ranked against; the outcome value itself is never adjusted for covariates — the percentile is a raw midrank within the matched set.
scripts/loaders/compute-percentiles.ts:141-156. - Key question (answered): the prototype matches on covariates; it does not regress them out. The spec's method fits
outcome ~ covariates (+ admission)per year and standardizes the residual — covariates enter the model, not a filter. Spec §3.1 (nyc-school-outcomes-spec.md:91-113); scaffoldpipeline/analysis/engine.py:36-53(fit_year). - Consequence: peer-group construction sensitivity (K, distance weights, the ×30 Jaccard scale at
nycenet-comparison-groups.ts:88) directly determines flags; the regression makes that a continuous, weight-free prediction. Spec §3.1 (nyc-school-outcomes-spec.md:93) and the team's own note (docs/agents/quantitative-knowledge.md:38-47). - Verdict nuance: the spec explicitly says keep peer groups as complementary display + a convergence cross-check (
nyc-school-outcomes-spec.md:93,150,287). So this is "diverges in role," not "throw away."
2. Outlier definition — 🟡 Diverges (the core methodological gap)
Why it matters: a fixed-10%-per-tail rule flags schools whether or not the gap is meaningful or just small-N noise, and produces a rank rather than an effect size with uncertainty.
- Prototype: top/bottom decile of the peer-group percentile —
comparison_group_percentile ≥ 0.9/≤ 0.1.lib/queries/schools.ts:166-172. The compute step has no reliability weighting; a 30-tested-student school and a 300-tested-student school are ranked identically (compute-percentiles.ts:141-156). Min peer count is the only guard (≥5, :148). - Prototype's own spec defers proportion/low-count handling to "v2":
SPECS.md:300,methodology/README.md:7. - Spec: covariate-adjusted standardized residual
z = r / √(s² + se_i²), flag|z| ≥ z_threshold(default 2.0), direction-aware.nyc-school-outcomes-spec.md:102-111; scaffoldengine.py:72-95(standardize,flag_outliers) +outliers.py:40-56. - Practical difference:
- Decile always flags ~10% of each tail per metric, regardless of whether the spread is real → over-flags in tight peer groups, under-flags in dispersed ones; flags noisy small-N schools at the extremes (exactly what
quantitative-knowledge.md:101-109warns about — "0% on n=3"). - Residual-z flags only schools whose gap is large relative to its own uncertainty:
se_i = √(p(1−p)/N)means a small-N school needs a much larger raw gap to clear|z|≥2(engine.py:56-79). It yields a continuous effect size, a fitted "expected" value, and a count that can be zero or many — not a forced 10%.
- Decile always flags ~10% of each tail per metric, regardless of whether the spread is real → over-flags in tight peer groups, under-flags in dispersed ones; flags noisy small-N schools at the extremes (exactly what
- Decision is deliberately deferred (per KX decisions in the runbook): run residual-z alongside decile, diff which schools flip, decide replace-vs-supplement after. This is sequenced first in
03_integration_plan.md.
3. Proclivity decile — 🟡 Diverges (display/control hybrid, not a statistical control)
Why it matters: if "expected outcome" is a separate decile badge rather than the model's prediction, the outlier flag and the "punching above its profile" narrative are computed from different notions of "expected."
- Prototype:
proclivity_decile=ntile(10)of the mean percentile rank across 3 challenge features (pctEconDis,pctEll,pctSwd).scripts/loaders/compute-proclivity.ts:17,92-115. - It is not a statistical control on the outcome: it is stored on the school and used only as a filter/sort in the outlier query (
lib/queries/schools.ts:176-181,195-196) and a display badge (components/proclivity-badge.tsx). The percentile/outlier computation never references it. - Reconcile with spec covariates: the spec's covariate set is broader — base demographic set = econ-disadvantaged/ENI, ELL, SWD, race composition (Black/Hispanic/Asian/White), enrollment size, plus band-specific admission (G&T for ES,
admissionsmethod_prog1for MS,method1for HS).nyc-school-outcomes-spec.md:54-68,373-393(covariates.yaml). The spec explicitly says: unify — the regression's covariate set subsumes proclivity; "expected" becomes the model's continuous prediction (nyc-school-outcomes-spec.md:288). So proclivity's 3 features are a strict subset of the model covariates. - Verdict: keep proclivity as a display grouping (it's a good plain-language hook —
nyc-school-outcomes-spec.md:279), but it is not the spec's control and shouldn't be conflated with it.
4. Trends / time — 🔴 Missing
Why it matters: a raw year-over-year delta on proficiency is exactly the view the spec calls "the single biggest reason a naive year-over-year change is wrong."
- Prototype:
getYearOverYearMovers= rawto_value − from_valuebetween two chosen years.lib/queries/schools.ts:272-318; rendered as a raw delta atapp/ratings/movers/page.tsx:45-51,110-113. - There is no time-dimension analysis beyond this: no within-year z, no slope, no regime, no persistence. The trend is on absolute proficiency, so it crosses scale breaks freely.
- Spec View 2: slope of the within-year standardized residual (z) fit within a comparability regime, with a t-test gate and leave-one-year-out sensitivity.
nyc-school-outcomes-spec.md:115-129; scaffoldpipeline/analysis/trends.py:18-60(fit_trend,flag_trend) +trend_runner.py. - The 2023 NextGen break: because the prototype trends on raw proficiency, a school can show a large "+delta" that is entirely the 2023→ standards reset or the 2024-25 cutscore move. The prototype's stories explicitly know this (
data/stories/answers.ts:1000,1920,2128), but the movers query/UI does not enforce it — the awareness is editorial, not systematic.
5. Comparability years — 🔴 Missing in code (handled editorially only)
Why it matters: mixing pre/post-NextGen test years or pre/post-COVID absenteeism years compares non-comparable regimes.
- Prototype loaders ingest all years 2018–2025 with no comparability flagging:
scripts/loaders/test-results.ts:12-23(fixed 2018–2025 files), no regime/exclusion logic anywhere inlib/orscripts/(grep: regime/comparability hits onlydocs/+data/stories/). - 2020/2021 are not excluded in code; 2022 is not treated as ambiguous; the movers query will happily diff 2019→2024 across the NextGen break.
- The data needed to detect the break exists —
Mean Scale Scoreis ingested alongside proficiency (test-results.ts:167-182) — but is not used for any comparability gate. - Spec:
config/comparability.yamlwith explicit regimes,excluded_years: [2020,2021],ambiguous_years: [2022],cross_regime_reporting: standardized_only.nyc-school-outcomes-spec.md:70-83,395-411; scaffoldconfig/comparability.yaml:5-26. - The editorial layer does the right thing manually (e.g.
data/stories/answers.ts:1770filters movers to schools with both endpoints comparable), confirming the team agrees with the spec — it just isn't encoded.
6. Suppression / small-N — 🟢 Aligned (suppression) · 🟡 (no reliability weighting)
Why it matters: showing suppression builds trust; but suppression ≠ down-weighting noisy-but-published small-N cells.
- Suppression handling is good and matches the spec: low-N cells detected (
s/*/n<5) at_fetch.ts:111-115, stored assuppressed=true, hidden from outlier lists by default with an opt-in toggle (lib/queries/schools.ts:207-214,includeSuppressed), shown not dropped perSPECS.md:301,264. Aligns withnyc-school-outcomes-spec.md:26,250-251. - But published-yet-small-N cells are not down-weighted in the outlier computation — only a min-peer-count guard exists (
compute-percentiles.ts:148). The spec'sse_iterm andmin_neligibility floor (outliers.py:46,outcomes.yaml:23) are the missing reliability mechanism. This is the same root as gap #2.
7. Survey rollups — 🟡 Diverges
Why it matters: if item wording/scale shifts across years, a cross-year survey trend (or even a level comparison) is measuring the instrument, not the school.
- Prototype rolls up surveys by regex-matching question text independently within each year (questions are year-versioned) and averaging
positive_pct.scripts/loaders/compute-survey-metrics.ts:25-99,113-128. It stores both NYCDOE-published and derived domain scores perSPECS.md:165-173,302— that transparency design is aligned. - But there is no verification that a matched item's wording/scale is continuous across years before the values are compared over time. The keyword lists are static; a re-worded item in a new year either silently drops out or silently joins the average.
- Spec requires an explicit instrument-stable window, set only after verifying continuity (
nyc-school-outcomes-spec.md:77,80,124,316); scaffold leavessurvey_regimesas an intentional placeholder pending that verification (config/comparability.yaml:27-35).
8. Provenance & determinism — 🟡 Diverges
Why it matters: "every figure traceable to a data vintage + code commit" and "byte-identical re-runs" are headline requirements; partial provenance can't gate a release.
- Provenance — partial-aligned.
DataLoadrecordssource_name,source_url,script_path,git_sha, row counts, notes per run (_lib.ts:30-62,prisma/schema.prisma:399-419). Good foundation. - Gaps vs spec §4.1/§5.1 (
nyc-school-outcomes-spec.md:171-176,214-221):- No source-file content hash (
sha256/bytes/retrieved_at/http_status) — cache is keyed by URL only (_fetch.ts:9-29); spec wants a per-fileprovenance.jsonmanifest. - No golden/determinism tests —
tests/unit/is onlyformat/normalize/routes; nothing asserts a re-run reproduces identical outputs. Scaffold hasmake golden+tests/test_golden.py+ committed fixtures (Makefile:43-51,tests/fixtures/golden/ela_math_g3/). - Determinism is also harder to guarantee because the pipeline mutates a live DB in place (e.g.
compute-proclivity.ts:124resets all deciles each run;nycenet-comparison-groups.ts:211-217deletes+recreates all groups) rather than producing a versioned, hashable artifact. Spec wants staged immutable artifacts → versioned JSON (nyc-school-outcomes-spec.md:159-167) withanalysis_table_sha256in every output (to_website_json.py:54-73).
- No source-file content hash (
9. Correctness vs reality — 🔴 Missing — the single largest gap
Why it matters: nothing currently confirms that a number the site shows for a specific school matches what NYC/NYSED officially report for that school. For a civic-transparency tool that may flag a named school as a "negative outlier," this is the highest-stakes check.
- No reconciliation / official-data correctness check exists anywhere in the prototype. A full-repo grep for
reconcile|verify|official|residual|ground.?truth|goldenfinds matches only in editorial docs (docs/agents/*,docs/editorial-process.md) describing the story-writing review process — not a data-verification harness. There are no reference adapters, no per-school cross-check, no QA reports. - Internal "verify" is also absent: there are no automated stage gates analogous to the scaffold's
checks.py(coverage matrix, finite-z, plausible flag counts). - Spec: a first-class verification subsystem —
verify/checks.py(internal gates) andverify/reconcile.py+references/adapters comparing the published value against an independent authoritative source, with QA reports and a--strictCI gate.nyc-school-outcomes-spec.md:160-176,210-231; scaffoldpipeline/verify/reconcile.py:1-233(four-layer check:source_raw/analysis_table/website_json/reference, headline = website-vs-reference),pipeline/verify/checks.py:1-78,pipeline/verify/references/{base,manual_csv,socrata,cached_file}.py. Themanual_csvadapter (references/manual_csv.py) is the lowest-friction starting point: a reviewer records official per-school figures in a CSV andreconcile.pydiffs against them. - Status — addressed in PR B (
verify/). A read-only validation harness now closes the correctness portion of this gap: (ii) base case (DB vs the exact ingested file — 0 mismatches across ~29k metric cells + 826,354 survey cells) and (iii) spot check (DB vs the NYC School Snapshot). Scope so far = the directly-ingested components,ALLsubgroup; (iv) computed-metric checks and a different-publisher (NYSED) cross-check remain pending. Seeverify/METHODOLOGY.md.
Summary read
The prototype is a well-built data warehouse + multi-persona site whose infrastructure (peer groups, suppression, universe tagging, survey dual-scores, provenance rows, long-format schema) closely matches the spec's intent and should be kept. The divergences are concentrated in the statistical core and verification spine:
- Outlier statistic is rank-based (decile), not model-based (residual-z) — no reliability weighting (#2, #6).
- Trend/time analysis is a raw cross-year delta with no regime handling (#4, #5).
- No correctness check against official per-school data at all (#9).
Notably, the prototype's own editorial standards already prefer the continuous
regression baseline and already reason in residual / comparability-regime terms
(docs/agents/quantitative-knowledge.md:38-47,18-36; data/stories/answers.ts),
so the spec's method formalizes a direction the team has independently arrived at —
which de-risks adoption.