# The calculation contract This directory is a strangler fig. The original calculations are the TypeScript in `../src/`; they still run, and they still produce the site. Around them, a second implementation is growing as R Markdown documents, and a third as Python scripts. This file is the seam they meet at. Three calculations have been written three times each: **retention** (teacher retention from the payroll extracts), **attendance** (chronic absence in Philadelphia and Brooklyn, and the federal three-city comparison), and **seda** (Philadelphia and Tuscaloosa on a common achievement scale). The pattern's point is that nothing is replaced by faith. A new implementation is only allowed to take over a part of the calculation once it demonstrably produces the same answer for that part, and the demonstration is a test that keeps running afterwards. The contract below is what "the same answer" means. ## The format One file per calculation per implementation, in `out/`, named `-.tsv`: | File | Written by | | --- | --- | | `out/-typescript.tsv` | `-emit.ts`, calling the existing `src/` modules | | `out/-r.tsv` | `.Rmd`, knitted | | `out/-python.tsv` | `.py` | Each is a tab-separated file with the header `metric`, `key`, `value`, one fact per line, sorted by metric then key, LF line endings, UTF-8, with a trailing newline. If the calculation is the same, the three files are byte-identical. Three rules make that achievable across three languages: **Counts stay counts.** Every rate is published as its numerator and its denominator, as integers, rather than as the quotient — `pooled.teacher.stayed` and `pooled.teacher.start`, not `pooled.teacher.rate`. Integers cannot drift, so most of the contract is exact by construction rather than by tolerance. **Reals get exactly twelve decimal places.** What remains real is small in magnitude — percentile ranks, correlations, medians of rates, attendance percentages, achievement estimates in grade levels — so twelve decimals is far finer than any disagreement worth having and coarse enough to absorb a last-bit difference in how three languages accumulate a sum. A value that does not exist is `NA`, never an empty cell and never a zero. Negative zero is written as zero. **Sort keys are printable ASCII with no spaces.** Byte order, code-point order and UTF-16 code-unit order are the same order for ASCII, so all three implementations sort identically without anyone reasoning about locale collation. Values may hold anything but a tab or a newline, because values are never sorted on: `metric` and `key` together are unique. ## What is in it ### retention | Metric family | What it covers | | --- | --- | | `input.*` | Rows, distinct identity keys, teacher and principal counts, and two SHA-256 digests per extract | | `peer.*` | Peer-set size, median, and quartiles, per transition, level and staff definition | | `series.*` | Stayers, starters and peer percentile for each school and transition, under each of the three staff definitions | | `pooled.*` | The same, pooled over the window and over 2020 onwards | | `alt.*` | PFT and student/contracted headcount per school-year | | `principal.*` | Tenure runs and the number of changes | | `era.*` | Retention pooled within each principal's tenure | | `cohort2020.*` | The 2020 teaching cohort, and how much of it is left each year | | `destination.*` | Where leavers went: another school, another role, or out of the district | | `correlation.*` | Principal churn and longest tenure against pooled retention, plus both input series | The two digests are the part that does the most work. `input.digest` hashes the six fields each implementation reads out of an extract, in file order, so that "three CSV readers appear to agree" becomes something a test asserts rather than something anyone assumes. `input.keydigest` hashes the sorted identity keys, so that the name-normalization rule is checked on its own rather than only through its effect on the counts. ### attendance | Metric family | What it covers | | --- | --- | | `input.*` | Rows and a digest per Philadelphia CSV; a raw-file digest per NYC and CRDC JSON | | `phl.*` | Average daily attendance, chronic absence, the 95% series and teacher attendance, per school-year | | `phlMedian.*`, `phlCount.*` | The districtwide median for each measure and year, and how many schools it is over | | `nyc.*`, `nycMedian.*` | The same two measures per Brooklyn school-year, and the citywide medians | | `federal.*` | The CRDC rate for every school in all three cities, for 2017-18 and 2022-23, with its city and name | | `federalMedian.*` | Median, range and count per city per collection, on the one common definition | | `correlation.*` | Chronic absence against teacher attendance, plus both input series | The JSON sources are hashed as raw bytes rather than as parsed columns: there is no cross-language question about how to read a JSON string, and what each implementation makes of the numbers is checked by the facts themselves. The CRDC files are where that matters most — they return two rows per school even with every subgroup pinned to "all", and keeping the homeless-subgroup row instead of the all-students row produces single-digit rates that look entirely plausible. ### seda | Metric family | What it covers | | --- | --- | | `input.*` | Rows and a digest per SEDA file | | `seda.*` | Level, growth, both national percentiles, demographics, name and rank for each of the thirty-nine schools in the three named sets | | `seda.n`, `seda.median`, `seda.withGrowth`, `seda.digest` | Per group: how many schools, the median level the page draws as a rule, how many have a growth estimate, and a digest of every school in it | | `coverage.*` | How many schools in each district SEDA has a level and a growth estimate for | | `seda.now*` | Current federal enrolment and Black enrolment per school, so no decade-old composition is published alone | | `national.*` | The size of the two national distributions percentiles are taken against | | `district.*`, `usMedian.*` | The 2019 and 2022 district estimates, the change, and the national median district | | `poverty.*` | How far level and growth track the share of students economically disadvantaged, across every school in the release, and how many schools sit at that measure's ceiling | | `set.*`, `povertyFit.*` | The five sets the page's intake table compares — count, median level, median low-income share, and share at the ceiling — and the national line their predictions are read off | ### A note on `perecd` `perecd` is the share of a school's students who are economically disadvantaged, and it is the input to every `poverty.*`, `set.poverty` and `povertyFit.*` fact. It is derived from free-and-reduced-lunch eligibility, which means a district serving universal free meals books nearly every school at the maximum whatever its families earn. Philadelphia has done that district-wide since 2014; Tuscaloosa City has not. The measure therefore has a ceiling, and the ceiling is not the same distance away for every district. Nothing in the contract can fix that — there is no income measure in the school-level release that is not built from meal eligibility. What the contract can do is make it visible, so `set.ceiling` and `set.ceilingOf` are emitted beside every median: how many of a set are recorded at or above `poverty.ceilingAt` (0.95), out of how many have a share at all. `poverty.ceiling` is the same count across the whole release, so any set can be read against the national rate. A set that is mostly at the ceiling cannot be placed on `povertyFit` at all, and the page says so where it draws the table. ## Definitions the three had to be told to share These are places where the languages' defaults disagree, and where all three implementations therefore say something more explicit than they otherwise would. - **`\b` is not the same everywhere.** JavaScript's word boundary is ASCII-only; Python's is Unicode-aware by default. The teacher-title test is written as an explicit character test, and Python passes `re.ASCII`. - **Uppercasing is not the same everywhere.** Only `A`–`Z` survive the identity key, so the three agree on today's extracts, where the only non-ASCII letter in any name is a single `É`. A future extract containing a character whose uppercase form *is* ASCII — `ı`, or the `fi` ligature — would make them differ. `input.keydigest` is what would catch it. - **Sorting is not the same everywhere.** R sorts strings by the session's locale unless told otherwise, so every sort in the R implementation passes `method = "radix"`, which is C-locale byte order. - **Summation order is not the same everywhere.** Pearson's r is written as an explicit loop in all three, rather than as `cor()` or a vectorized sum, so the terms are added in the same order. - **A duplicated name resolves to the last row.** Building a person-to-school index by assignment in file order means the last row wins. R's named-vector lookup would take the first, so the R implementation deduplicates with `fromLast = TRUE` to match. The same applies to every id-keyed index in the attendance and SEDA calculations. - **Rounding halves is not the same everywhere.** JavaScript's `Math.round` sends halves up; R's `round` and Python's `round` both send halves to the nearest even number. A SEDA percentile landing exactly on 46.5 would come out 47 in the original and 46 in a naive port, so all three compute `floor(x + 0.5)` explicitly. This is the likeliest place for three implementations to disagree by one. - **What counts as a number is not the same everywhere.** Python's `float` and R's `as.numeric` accept strings JavaScript's `Number` rejects, and vice versa. The SEDA reader checks the shape of the string against an explicit pattern before converting, so the three agree about what is *not* a number too. - **A byte-order mark is not stripped everywhere.** One Philadelphia attendance file carries one. Left in place it becomes part of the first column's name, so every lookup on that column silently returns nothing — which produces an empty chart rather than an error. All three strip it from the raw bytes, before decoding. - **New York's school ids are not the federal ones.** SEDA rolls the city's 32 geographic districts into one synthetic citywide LEA (`3620580`), where CCD and CRDC keep them apart (`3600091` is District 13). The five-digit school number survives, so ids crosswalk by swapping the prefix; the contract emits `seda.district` for every school, so all three implementations have to agree that the crosswalk landed in that LEA. - **A group too large to write out is hashed instead.** Three of the four SEDA groups are small enough to emit school by school. The fourth is every school in Brooklyn — four hundred of them, which at twelve facts each would be nine tenths of the contract and would bury the rest. All four groups therefore also carry `seda.digest`: the SHA-256 of a dump with one line per school, fields separated by `U+001F` and lines by `\n`, no trailing newline, in the group's own order. The fields are exactly the values the per-school facts carry — id, name, district, grade level, level, growth, both percentiles, percent Black, enrolment, current enrolment, current Black enrolment, rank — each written by the same formatter as the corresponding fact, so `NA` appears wherever the fact would say `NA`. One differing field in one school changes the hash. The cost is that a mismatch names the group rather than the school; `seda.n`, `seda.median` and `seda.withGrowth` are emitted beside it so the commonest causes are still named directly. - **Coalescing on absence is not coalescing on emptiness.** The original uses `??`, which falls through only for a missing value, so a school with a blank name keeps the blank rather than falling back to the next candidate. Python's `or` and R's `if (!nzchar(x))` would both fall through; the ports test for presence instead. ## Migration ledger Where the fig has actually reached. "Migrated" means the R Markdown and Python implementations compute it and are checked against the original on every test run; it does not yet mean the original has been removed. | Part of the calculation | Status | | --- | --- | | Reading and parsing the October extracts | migrated — all three, verified by digest | | Identity keys, teacher/alt/principal definitions | migrated — verified by digest | | Year-over-year retention, all three staff definitions | migrated | | Peer sets, medians, quartiles, percentile ranks | migrated | | Pooled retention, including the 2020-onwards window | migrated | | Principal tenures, changes, per-era retention | migrated | | 2020 cohort survival | migrated | | Leaver destinations | migrated | | Churn/tenure correlations | migrated | | Philadelphia attendance, all four series | migrated — all three | | New York chronic absence and citywide medians | migrated | | Federal CRDC three-city comparison | migrated | | Chronic absence vs teacher attendance | migrated | | SEDA level, growth and national percentiles | migrated — all three | | Every school in Brooklyn, beside the ten that were named | migrated — verified by digest | | The New York id crosswalk into SEDA's citywide LEA | migrated | | Current demographics from CCD, joined to the SEDA schools | migrated | | SEDA district estimates and the national median district | migrated | | The poverty correlations across every US school | migrated | | The intake table's five sets, and the line they are read off | migrated — all three | | How much of each set sits at the low-income measure's ceiling | migrated | | School labels, notes, display ordering | **not migrated** — presentation, stays in TypeScript | | Catchment and zone lookups (`src/places.ts`) | **not migrated** — geography, stays in TypeScript | | Rendering the site's `*-data.ts` modules | **not migrated** — the original still writes the site's data | The last row is the one that matters for what "strangled" would eventually mean. The site is still built by `npm run analyze`, which runs the TypeScript. What these contracts establish is that it *could* be built by either of the other two, and that the choice is now a routing decision rather than a rewrite. ## Running it ```sh ./calc/run.sh # nine runs, then the comparison npm run calc:ts # just the TypeScript emitters npm run calc:py # just Python npm run calc:r # just the R Markdown (needs R + knitr, digest, jsonlite) npm test # asserts the committed outputs are identical ``` `npm test` compares the committed `out/*.tsv` and needs neither R nor Python installed. `run.sh` regenerates them and needs both.