Every year the Department of Examinations in Sri Lanka publishes district cut-off marks for the Grade 5 scholarship exam. The mark is out of 200, and it differs by district and by medium (Sinhala or Tamil). It is published as an image. I turned it into typed data for the Grade 5 scholarship cut-off marks page. Most of what I learned is not about exams. It is about hand-typed data that has holes in it.

The shape: a record keyed by name, not an array

The first decision was how to store 25 districts x 2 mediums x 3 years. A positional array is the obvious choice, and I rejected it. The comment at the top of grade5-cutoffs.ts says why:

Keyed by district name, not positional arrays. This file is edited by hand once a year when the new sheet lands, and a value shifted one column in a positional array is exactly the kind of error a person cannot see.

So the table is a Record keyed by district, with a tiny helper to keep each row on one line:

export type Cutoff = { readonly si: number | null; readonly ta: number | null }; const c = (si: number | null, ta: number | null): Cutoff => ({ si, ta }); export const G5_CUTOFFS: Readonly<Record<G5Year, CutoffTable>> = { 2026: { COLOMBO: c(139, 135), "NUWARA ELIYA": c(133, 132), JAFFNA: c(null, 133), MANNAR: c(125, 131), // ...25 districts }, 2025: { /* ... */ }, 2024: { /* ... */ }, }; 

The district list is a const tuple with satisfies readonly DistrictKey[], and the year table is Record<G5District, Cutoff>. If I forget a district when typing next year's sheet, the compiler complains. A misspelt key fails the same way. A person squinting at a column of numbers would not notice either mistake. The type checker does.

The three years sit in one object keyed by year. That is what lets the page show them side by side. The page indexes G5_CUTOFFS[year][district][medium] and nothing else, and the "vs previous year" column is the same lookup with year - 1.

null is not zero

Some cells are empty. The Department published no Sinhala-medium cut-off for Jaffna in any of the three years, and none for Kilinochchi in 2024 and 2026. In 2025 Kilinochchi has 127. The Department has not said why, so the file stores null and says in a comment that "too few Sinhala-medium candidates" is only my own inference.

The risk with null in JavaScript is how forgiving it is. Math.min(139, null) is 0, because null coerces to zero. A "lowest district" fact computed naively would report a cut-off of 0. So g5Range in facts.ts filters first and keeps the count of what it actually used:

export function g5Range(year: G5Year, medium: Medium) { const published = G5_DISTRICTS.flatMap((d) => { const v = G5_CUTOFFS[year][d][medium]; return v === null ? [] : [{ d, v }]; }); const min = Math.min(...published.map((p) => p.v)); const max = Math.max(...published.map((p) => p.v)); return { min, max, minDistricts: published.filter((p) => p.v === min).map((p) => p.d), maxDistricts: published.filter((p) => p.v === max).map((p) => p.d), published: published.length, }; } 

I ran it against the real data. For 2026 Sinhala medium it returns min: 125, max: 139 and published: 23. It is 23 and not 25 because Jaffna and Kilinochchi are skipped. Tamil medium returns min: 130, max: 135, published: 25.

Note that it returns the list of districts at each end, not one district. Ties are normal in this data: nine districts share the 139 top band. A function that returned "the highest district" would pick one of them arbitrarily and the sentence built from it would be wrong.

District names stored once, in three languages

The page is available in English, Sinhala and Tamil. District names are the part where translation matters most, because a parent looks for their own district. The names are stored once, keyed by the same upper-case English key used in the cut-off table. The Sinhala and English labels come from a shared table that the university cut-off pages also use. The Tamil labels live in grade5-i18n.ts, because no other page needed them yet:

const DISTRICT_TA: Record<DistrictKey, string> = { COLOMBO: "கொழும்பு", "NUWARA ELIYA": "நுவரெலியா", JAFFNA: "யாழ்ப்பாணம்", // ... }; export function g5District(key: DistrictKey, lang: G5Lang): string { return lang === "ta" ? DISTRICT_TA[key] : districtLabel(key, lang); } 

Because it is Record<DistrictKey, string>, a missing Tamil name is a compile error. Running it gives:

en Nuwara Eliya Jaffna si නුවරඑළිය යාපනය ta நுவரெலியா யாழ்ப்பாணம் 

The data tables never contain a display name. Only the key is stored with the numbers, and the label is looked up at render time.

Key facts are computed, not typed

Under the page's intro there is a box of key facts. The header comment of facts.ts states the rule: nothing in it types a figure in. When a number in the table is corrected, the prose moves with it. grade5KeyFacts() builds sentences from g5Range, g5Unpublished and a reduce over the 12-year history:

const lowest = history.reduce((a, b) => (b.si < a.si ? b : a)); const peak = history.reduce((a, b) => (b.si > a.si ? b : a)); 

Here is the output I got from running it:

Across 25 districts the 2026 Sinhala-medium cut-off runs from 125 (Mannar, Vavuniya, Mullaitivu and Batticaloa) to 139, and the Tamil-medium cut-off from 130 (Anuradhapura and Polonnaruwa) to 135. 139 is the lowest Sinhala-medium common cut-off in the 12 years from 2015 to 2026; the peak was 169 in 2020. 

The trend sentence has two templates. If the latest year is the lowest, it says so. Otherwise it says when the low was. That branch exists because "this is the lowest in 12 years" is only true until next year's sheet lands. A hard-coded sentence would go stale without anyone noticing. The facts file also gets a second check: a QA test recomputes the headline values a different way, to catch a bug in the facts code itself.

Year-on-year change is the one thing the page does in the UI, not in facts.ts, but the principle is the same. Comparing the 2026 table with the 2025 table:

District 2025 si / ta 2026 si / ta
Colombo 140 / 134 139 / 135
Mannar 127 / 131 125 / 131
Anuradhapura 133 / 131 133 / 130

So Sinhala medium in Colombo fell by one mark while Tamil medium rose by one. The cut-offs for the two mediums do not move together.

Honest limits

  • No machine-readable source. The sheet is an image, so every cell is hand-transcribed. Each year's numbers were taken from at least two independent transcriptions that agree. One cell did not: Ampara, Tamil medium, 2025, was 131 in one transcription and 132 in two others. The file uses 132 and records the disagreement in a comment.
  • Candidate counts are only partly checked. For 2024 and 2025 the per-district numbers come from the Department's statistical report, and I assert that they sum to its all-island totals. 2026's report was not out when I wrote the data. The "about 56,000 qualified" figure for 2026 comes from a single news report, so the type carries a qualifiedIsReported flag and the sentence says it is unconfirmed.
  • Not covered. Cut-offs for individual schools at Grade 6 admission are not in this data file. They exist only as scanned tables, and I did not want to publish OCR guesses to parents as if they were facts.
  • A cut-off is not a pass mark. A child on or above their district's cut-off is counted as qualified, which makes them eligible for the bursary and to apply for schools. The mark also changes from year to year, as the table above shows.

What I would reuse

If you keep a small dataset that people edit by hand, three things carry over. Key by name rather than position. Use null for "not published" and make every aggregate filter it explicitly. Generate the sentences from the data, with one template per case that could flip.

How do you handle "no value" in your own datasets? Do you use null, a sentinel or a separate flag, and has it ever turned into a zero somewhere you did not expect?

This article was written with the help of AI. The code, numbers and sources come from my own project and were checked against it before publishing.