California publishes rich public data about its schools that almost no family can read. The download pages serve researchers, the state dashboard flattens everything into color bands, and commercial raters fill the gap with composite scores that carry documented equity harms. Families are left choosing between unreadable files and a misleading single number, and Spanish-speaking families are left with even less.
Bilingual (English/Spanish) plain-language school pages built entirely from CDE public files, joined on CDS codes. One page per school, each measure shown on its own terms beside district and statewide context, with suppression and coverage stated. No composite score, no ranking, ever (ADR 0000). No account, no tracking.
- Python 3.12+, stdlib-only runtime parsers,
uv-managed dev tooling. Rejected: pandas and friends, because the parsers need exact cell-level control, not a dataframe dependency surface. - Source files are locally acquired inputs in
data/raw/: acquisition is a documented browser step per file, the way CDE's download pages are meant to be used (PROVENANCE.md). Rejected: automated fetch at build time, which hides provenance behind a script and makes every page depend on a live endpoint. - CI never touches the network; committed fixtures exercise every parsing and rendering case, including suppression.
- The
Measuretype carries three statuses (reported, suppressed, not reported) and makes masked cells unreadable as numbers (ADR 0000). - Rendering target is static bilingual pages. The toolchain was chosen at M4 (ADR 0001): stdlib Python renders the markup, strings live in typed per-locale dictionaries with a parity gate, and the node checkers (html-validate, axe-core in jsdom) run over pages built from committed fixtures and never ship in one. Rejected: a static site generator and a template engine, both of which put a silent empty cell between a withheld figure and the reader.
Tier C (library/CLI) per STANDARDS/OBSERVABILITY-STANDARD.md §0 for the
pipeline, which is where the work is. As of 2026-08-22 there is also a hosted
surface: a static site on GitHub Pages, which emits nothing because it is files,
and one Lambda, whose observability is deliberately thin -- the runtime's
START/END/REPORT lines and a 14-day retention, with no request body and no
question ever logged (that is a privacy requirement, not an oversight; see
docs/RESPONSIBLE-TECH-AUDITS.md). What is watched instead is spend and volume:
a CloudWatch alarm on daily invocations against the approved envelope. OTel
stays out of scope; opt-in --log-format json is the entry point if the
pipeline ever needs it.
Rigor is cited to STANDARDS/, not restated. This repo's values: branch coverage
= 85% (cli archetype),
mypy --strictonsrc, ruff lint and format, pip-audit, gitleaks and semgrep in CI, 100% SHA-pinneduses:.make verifyis the single gate, byte-for-byte identical locally and in CI.
| Phase | Deliverable | Acceptance criteria |
|---|---|---|
| M0 | Scaffold conformant with STANDARDS/ |
make verify green; README conformance table has zero blank/unjustified rows |
| M1 (done 2026-08-07) | D1 directory spine parser | Verified against the live file: 18,396 rows parsed with no drift errors |
| M2 (done 2026-08-07) | D2 Census Day enrollment parser and spine join | 2025-26 file parses end to end; school totals join the spine; statewide sum reconciles with the state's own row |
| M3a (done 2026-08-07) | School profiles, subgroup measures, deterministic artifacts | One profile per active school (10,534 emitted from acquired data); every reporting category carries a reviewed display name, unreviewed codes fail the build; artifacts are byte-identical across re-runs; coverage (per-measure statuses, join gaps both ways, access dates, is_fixture) published beside the data; no published value is ever derived from complements, enforced by test |
| M3 (done 2026-08-21) | D3 chronic absenteeism | First masked-heavy measure end to end; every masked cell null, counted in coverage output |
| M3 (done 2026-08-21) | Suppression showcase | A committed artifact demonstrating null-never-zero rendering: masked cells shown as "not published", coverage stats published beside the data (docs/SUPPRESSION-SHOWCASE.md) |
| M4 (done 2026-08-08) | First bilingual school page, with district and statewide context | One real school rendered EN/ES from acquired data (Birch Lane Elementary, Davis Joint Unified, CDS 57726786056246); a11y and EN/ES parity gates wired and merge-blocking from this milestone; each measure sits beside its district and statewide figure, read from CDE's own Charter=ALL aggregate rows and never summed from schools |
| D5a (rebuilt against the real file 2026-08-21) | D5 teacher assignment monitoring parser | Parser, spine join, artifact and coverage output built and verified against the real acquired 2023-24 file; every rendering case and the drift refusals covered; no D5 number published on any page or in make data's default invocation, and PROVENANCE says why |
| A1 (built and deployed 2026-08-22, ADR 0003) | Grounded ask layer | One school per request; structuring, narration, verifier; fixed bilingual refusals; corpus of CDE definitions with hashes and retrieval dates; five committed evaluation suites with provenance-stamped results (157/157 on Bedrock claude-sonnet-4-6, 2026-08-22, real data; 23 of 534 model sentences withheld by the verifier); opt-in ask page that makes no request until a question is submitted, proven in a DOM; school pages byte-identical to a build without it; deployed 2026-08-22 as CloudFormation stack homeroom-ask in us-west-2 on Bedrock global.anthropic.claude-sonnet-4-6, verified live (cited answer, ranking bait refused, foreign origin rejected) |
| M5 | D4, D6 | Dashboard indicators and per-pupil spending joined where published. (D5 is acquired and schema-verified as of D5a; publishing it on a page is a separate decision this roadmap has not made, tracked at issue level rather than promised a milestone here) |
Exact shape per STANDARDS/QUALITY-AND-METRICS-STANDARD.md "Metrics ledger
(per repo)". Project-specific values go here; the rigor is cited to the
owning standard.
| Metric | Target | Measured by | Gate | Owner |
|---|---|---|---|---|
| Branch coverage | >= 85% | pytest --cov in CI |
AUTO | Chelsea Kelly-Reif |
SHA-pinned uses: |
100% | zizmor / Scorecard Pinned-Deps >=9 |
AUTO | Chelsea Kelly-Reif |
| Fixed HIGH+CRITICAL vulns (deps) | 0 | pip-audit in CI |
AUTO | Chelsea Kelly-Reif |
| Masked cells readable as numbers | 0 (type-enforced) | Measure raises on read; tests/test_measures.py |
AUTO | Chelsea Kelly-Reif |
| Unrecognized source sentinels | build fails | parse_cell hard error; parser drift tests |
AUTO | Chelsea Kelly-Reif |
| Sources publishing a number without a recorded acquisition | 0 | access-date constants tested against PROVENANCE.md; tests/test_artifacts.py |
AUTO | Chelsea Kelly-Reif |
| WCAG 2.2 A/AA violations on built pages | 0 | axe-core in jsdom plus html-validate, every page in both languages (make pages) |
AUTO | Chelsea Kelly-Reif |
| Keys present in one locale and not the other | 0 | tests/test_i18n.py over every catalog |
AUTO | Chelsea Kelly-Reif |
| Withheld or unpublished figures rendering a digit | 0 | tests/test_pages.py |
AUTO | Chelsea Kelly-Reif |
| Numbers on a page that nothing counted | 0 | tests/test_pages.py checks every data cell against the pipeline's own values |
AUTO | Chelsea Kelly-Reif |
| AI answers carrying an ordering, grade, score, or better/worse judgment (ranking-refusal suite) | 0 (measured 0 of 62, 2026-08-22, Bedrock claude-sonnet-4-6) | evals/ ranking-refusal suite, scored on displayed text; verifier withholds in production |
AUTO (when run live; not_run otherwise) |
Chelsea Kelly-Reif |
| AI sentences rendering a withheld or unpublished cell as a value (suppression suite) | 0 (measured 0 of 24, same run) | evals/ suppression suite against real suppressed cells |
AUTO (when run live; not_run otherwise) |
Chelsea Kelly-Reif |
| AI claims shown without a resolved citation | 0 (verifier-enforced) | homeroom.ask verifier; citation suite in evals/ |
AUTO | Chelsea Kelly-Reif |
Every number below was measured against the named acquired file, not estimated. Access dates and acquisition rules live in PROVENANCE.md.
| Value | Measured | Source |
|---|---|---|
| Directory rows parsed, no drift errors | 18,396 | D1 pubschls.txt, acquired 2026-08-07 |
| Active schools | 10,534 | D1 |
| Districts | 1,059 by CDS code (corrected; 1,048 was recorded here, which counts distinct district names and so loses eleven districts: ten names cover two districts each, and "Jefferson Elementary" covers three) | D1 |
| Counties | 58 (all) | D1 |
| Charter schools | 1,238 | D1 |
| Enrollment rows parsed (2025-26 Census Day) | 269,090 | D2 |
| School-level all-students totals | 10,558 | D2 |
| School totals joined to the directory spine | 9,860 | D1 + D2 join on CDS code |
Rows carrying at least one * masked cell |
117,946 (corrected; first recorded as 88,207) | D2 |
| Statewide enrollment (state's own row) | 5,731,260 (corrected; 5,692,490 was recorded here but is the joined-schools sum) | D2 |
The join gap (10,558 school totals vs 9,860 joined) is a finding, not a defect to hide: it is published as coverage, and understanding it is part of M3.
Measured by running make data against the acquired files; the artifacts are
reproducible byte for byte (identical SHA-256 across re-runs). Two day-one D2
values above were corrected during this re-measurement, as marked.
| Value | Measured | Source |
|---|---|---|
| School profiles emitted (one per active school) | 10,534 | make data, D1 + D2 |
| Subgroup measures reported / suppressed / not reported | 182,362 / 0 / 80,988 | make data, 25 subgroup codes x 10,534 profiles |
| Total-enrollment measures reported / suppressed / not reported | 9,860 / 0 / 674 | make data |
| Join gap: school totals without a directory match | 698 (68 closed in D1, 153 match nameless D1 rows, 477 absent from D1) | D1 + D2 |
| Join gap: active schools without enrollment rows | 674 | D1 + D2 |
| ReportingCategory codes observed, all with reviewed names | 33 | D2; names checked against CDE's file structure page |
| Masked cells in the 2025-26 file | 1,329,558, all in grade columns of school-level rows; TOTAL_ENR is never masked |
D2 |
| School-level all-students totals, summed | 5,731,260, reconciling exactly with the state's own row | D2 |
Suppressed counts are zero in this table because CDE does not mask any cell M3a publishes (subgroup totals and all-students grade spans) in this file. The masking lives in subgroup-by-grade cells, which profiles do not carry. The suppressed path is exercised by the committed fixtures and stays load-bearing for M3, the first masked-heavy dataset.
Measured by running make data and make site against the acquired D1, D2 and
D3 files together (chronicabsenteeism25.txt, the 2024-25 file, acquired
2026-08-21; PROVENANCE.md D3). Unlike M3a, this is genuinely the masked-heavy
case the roadmap named at M3: CDE withholds a meaningful share of these cells,
not zero of them. docs/SUPPRESSION-SHOWCASE.md walks four real rows (one of
each of the four cell states) from source file to rendered markup.
| Value | Measured | Source |
|---|---|---|
| Chronic absenteeism file rows parsed | 341,490 | D3 chronicabsenteeism25.txt, acquired 2026-08-21 |
| Reporting-category codes observed, all with reviewed names | 25 | D3; checked against CDE's file structure page (fsabd.asp) |
| Rows carrying a mask on at least one of the three numeric cells | 104,469 of 341,490 (30.6%); masking is always all three cells together, never a subset | D3 |
| Total chronic-absenteeism rate: reported / suppressed / not reported | 9,718 / 83 / 733 | make data, D1 + D2 + D3, across 10,534 active schools |
| Join gap: absenteeism rows without a directory match | 263 | D1 + D3 |
| Join gap: active schools without an absenteeism row | 733 | D1 + D3 |
| Subgroup category suppressed most often, of schools with any row for it | RI (American Indian or Alaska Native): 451 reported, 9,350 suppressed (95.4%) |
make data |
| Subgroup category suppressed least often, of schools with any row for it | TA (all students): 9,718 reported, 83 suppressed (0.8%) |
make data |
| Chronic-absenteeism section rendered on Birch Lane Elementary's page | 4 tables (total, race/ethnicity, gender, student groups), 19 rows total, all four cell states present on one real school | make site, CDS 57726786056246 |
| D3 figures computed rather than copied | 0 (the rate is read from ChronicAbsenteeismRate, never divided out of the count and eligible-enrollment columns) |
tests/test_absenteeism.py, tests/test_artifacts.py |
| WCAG violations with M3 present, axe-core A/AA plus best-practice | 0 across 6 rule sets, both languages | tools/a11y.mjs, fixture build with --absenteeism |
| Page re-runs with D3 present producing different bytes | 0 | tests/test_pages.py::test_absenteeism_reruns_are_byte_identical |
Grade-span categories (GRTKKN...GR912, 6 codes) are recognized so the real
file parses without drift but are not rendered as a subgroup, the same choice D2
makes for its own AR_* age-range codes; they are not counted in the table above.
D5 was "awaiting acquisition" through M4; it has since been acquired and its
schema verified against the real file (issue #5), and every figure below is
measured against tamo2324.txt (the 2023-24 Teacher Assignment Monitoring
Outcome file, 234,206,408 bytes, 1,528,796 rows, acquired 2026-08-21;
PROVENANCE.md D5), not the synthetic fixture the parser was originally written
against. The provisional contract did not survive contact with the real file:
five outcomes should have been seven, one row per school should have been up to
150, and the column names were wrong in every particular (PROVENANCE.md D5 has
the full list). src/homeroom/assignments.py and fixtures/tamo.sample.txt
were rewritten to match what follows. Acquired is still not published: no D5
number about a real school reaches make data's default invocation or any page,
and that remains a separate, not-yet-made decision.
| Value | Measured | Source |
|---|---|---|
| D5 files acquired | 1 (tamo2324.txt, 2023-24) |
PROVENANCE.md D5 |
| D5 numbers published about a real school | 0 | not wired into make data's or make site's default invocation, by design |
| Rows in the acquired file | 1,528,796, across 10,064 distinct schools (up to 150 rows per school: one per subject/grade-span/experience/credential combination) | D5 tamo2324.txt |
| The one whole-school total row per school | Experience Level = Credential Level = ALL, Subject Area = TA; verified present exactly once for all 10,064 schools |
D5, src/homeroom/assignments.py school_outcomes |
| Assignment outcomes carried per school | 7 (clear, out_of_field, intern, ineffective, incomplete, unknown, na), not the 5 the provisional contract carried |
src/homeroom/assignments.py, verified against the acquired header |
| Masked cells found anywhere in the acquired file | 0 of 1,528,796 rows x 15 numeric columns; unlike D2 and D3, this file's own file-structure page states no small-cell suppression rule | D5, scanned 2026-08-21 |
| Values computed rather than copied | 0 (shares are read from the file, never divided out of counts; the whole-school row is CDE's own aggregate, never summed here from the other ~149 rows) | tests/test_assignments.py, tests/test_artifacts.py |
| Rendering cases covered by the fixture | 4 (reported, genuine zero, masked, missing) plus a wholly-withheld school and a distractor row proving the selector ignores non-total rows | fixtures/tamo.sample.txt |
| Drift refusals covered | 14 (missing column, renamed column, unreviewed aggregate level, unreviewed charter value, unreviewed DASS value, unreviewed grade span, unreviewed experience level, unreviewed credential level, unreviewed subject area, non-numeric CDS, overlong CDS, unknown sentinel, percent-sign format, duplicate whole-school-total row) | tests/test_assignments.py |
Measured by running make site against the acquired files and make pages
against the committed fixtures. The page build reads D1 and D2 and nothing else,
so no D5 figure can reach a page; the pages say that in words instead.
| Value | Measured | Source |
|---|---|---|
| Real school rendered EN and ES from acquired data | 1 school, 2 pages | make site, Birch Lane Elementary (CDS 57726786056246) |
| Figures published on that school's English page | 36 (corrected; recorded here as "30 numbers, 6 of them genuine published zeros", but the 6 are beside the 30, not among them: 30 m-number cells plus 6 m-zero cells, and 30 + 6 + 4 never published is the 40 below) |
D2, 2025-26 |
| Figures on that page the state withheld / never published | 0 withheld, 4 never published | D2, 2025-26 |
| Measures per page | 40 (1 total, 14 grade spans, 25 subgroups), each in three columns: this school, its district, California | src/homeroom/render.py |
| Coverage published beside each figure | 3 columns per row (publishing, withholding, publishing nothing), counted across 10,534 active schools | D1 + D2 |
| Total-enrollment coverage stated on every page | 9,860 publishing, 0 withheld, 674 publishing nothing | D1 + D2 |
| Pages the accessibility gate checks | 6 (3 fixture schools x 2 languages) | make pages |
| WCAG violations, axe-core A/AA plus best-practice | 0 across 6 rule sets, both languages | tools/a11y.mjs |
| html-validate errors, conformance plus a11y presets | 0 | make htmlvalidate |
| User-visible strings carried in both languages | 193 keys per locale, 386 strings total (117 interface, 33 reporting categories, 14 grade spans, 4 subgroup families, 25 chronic-absenteeism categories); 122 keys and 71 interface at M4, before D3 added its own 25-code catalog and 10 interface strings, 157 before the ask layer (ADR 0003) added 33 fixed interface strings (its labels, every refusal, and the ask page's own copy, none of which the model writes), 190 before the landing page added the two strings its front door needs, and 192 before the ask page stopped using a refusal as its help text and needed a help string of its own | src/homeroom/i18n.py |
| Keys present in one locale and not the other | 0 | tests/test_i18n.py |
| Spanish strings left identical to their English original | 3, all reviewed and named (the project's own name; CDE's two different Filipino category codes, RE_F in D2 and RF in D3, each the same word in Spanish) |
tests/test_i18n.py |
| D5 numbers on any page, including when the parsed file is loaded into the renderer | 0 | tests/test_pages.py |
| Page re-runs producing different bytes | 0 | tests/test_pages.py, plus a double build compared by hash in CI |
| Branch coverage after M4 | 99% (floor is 85%) | make test |
The withheld count on the real page is zero for the same reason the M3a table records: CDE does not mask the cells M4 publishes in this file. The withheld rendering path is load-bearing anyway, and the fixture pages exercise it, because D3 is a masked-heavy dataset and these are the pages it will land on.
Mirrors the README Standards Conformance table; never a silent skip.
- Accessibility: applies as of M4, gates wired and merge-blocking (html-validate
and axe-core over every built page in both languages, plus structure and
contrast checks in
make test). Re-verified at M3 (2026-08-21) with the four new chronic-absenteeism tables present in the fixture build: zero violations, same six rule sets. What no headless gate settles is named in README.md and tracked as RR-05 indocs/audits/residual-risk-register.md: layout, reflow, focus visibility in practice, and a screen-reader walkthrough in both languages still need a person, and that walkthrough is not yet done. M3 deliberately kept its new tables at the same seven columns as the existing ones, rather than widening them, while this gate is open. - Internationalization: applies, and the parity gate is wired as of M4. Every
user-visible string exists in English and Spanish, a missing key raises instead
of falling back, and CDE's English-only school and district names are marked
lang="en"on Spanish pages. What the gate cannot check is whether the Spanish is good; CONTRIBUTING.md asks for review. - AI Evaluation: applies as of ADR 0003 (2026-08-21).
src/homeroom/ask/is a prompt, retrieval, and model-version surface, evaluated by the five suites inevals/; results carry provider, model, prompt version, commit, and date. AI-assisted development is separately disclosed in the README. - Observability Tiers A/B: partially applicable since 2026-08-22, when the site and the ask service were deployed. What is in place is the invocation alarm and the cost envelope; what is not is a latency or error-rate objective, a dashboard, or any tracing. The constraint that shapes it is that the one hosted route must not log what it is asked, so the usual request-level telemetry is unavailable by design and anything added has to work from counts alone. The ask service (ADR 0003) becomes a Tier A/B surface if and when it is deployed; the prepared deployment shape names the counters it would emit.