Every step, with the math.
This is the authoritative spec behind the estimate. Any drift between it and the code is a bug — raise an issue via /contact before changing either.
This document describes exactly how lib/calc/pipeline.ts converts raw NAPLAN inputs into
an ATAR estimate, a conservative free-tier floor, and a 95% confidence interval. It is
the authoritative spec for statistician review. Any drift between this document and the
code is a bug; raise an issue before changing either.
Notation
- Post-2023 NAPLAN domains:
reading,writing,numeracy,grammar(shown to readers as "Grammar & Punctuation"). The pre-2023conventions/spellingdomain has been removed. LIT = {reading, writing, grammar},NUM = {numeracy}.- A sitting is one NAPLAN test event:
{ year, year_level ∈ {3, 5, 7, 9}, scores: partial map of domain → raw_score }. - Inputs may include multiple sittings (current + optional −2, −4, −6 years), up to 4 total.
- All "percentiles" below are on a 0–100 scale.
Step 1 — Percentile conversion
For each (year, year_level, domain, raw_score) present in the inputs, look up its
percentile from lookup_domain_percentile. This table is populated per the client's QLD
cohort data (placeholder sigmoid in v1). A nearest-match within ±5 of the raw score is
acceptable when an exact row is missing.
The percentiles field in the result payload is keyed as ${domain}_${year_level}.
When the same (domain, year_level) appears in multiple sittings, the newest sitting's
percentile wins.
Step 2 — Gate checks
Two short-circuits before any ATAR estimate is computed:
single_score— if the total number of domain scores across all sittings is ≤ 1, return percentile(s) only. No ATAR estimate.gated_reason = 'single_score'.needs_num_and_lit— if no sitting contains a numeracy score, OR no sitting contains any LIT score, return percentile(s) only.gated_reason = 'needs_num_and_lit'.
Otherwise gated_reason = null and we proceed.
Step 3 — NAPb baseline
Define per-sitting baselines:
bNUM = max over all sittings of (numeracy percentile in that sitting).- For each sitting, compute the mean of LIT percentiles that are actually present in that
sitting (e.g. a sitting with only reading and writing contributes
mean(reading, writing)). ThenbLIT = max over all sittings of (that sitting's LIT mean).
Combine:
- If
|bNUM − bLIT| > 20: discard the lower one;raw_NAPb = max(bNUM, bLIT). - Else:
raw_NAPb = (bNUM + bLIT) / 2.
Step 4 — Adjustments
Compute each independently, sum them, then apply the cap. Adjustments lift the paid-tier point estimate above the conservative baseline. They are not included in the free-tier floor.
Sector
| sector | adjustment |
|---|---|
indep |
+2 |
cath |
+1 |
gov |
+0 |
ICSEA match
Convert ICSEA to a percentile assuming N(mean = 1000, sd = 100) (see lib/calc/icsea.ts):
icsea_pct = 100 × Φ((ICSEA − 1000) / 100)
If |bNUM − icsea_pct| ≤ 5 or |bLIT − icsea_pct| ≤ 5, add +2. Otherwise +0.
Domain disparity
If any individual NUM percentile (across any sitting) and any individual LIT percentile differ by > 10, add +4. Otherwise +0.
(A revised, more conservative disparity formula is planned and will replace this flat +4 rule. Implementation is pending the regressed coefficient.)
Inter-year trend
Applied only when both a Y7 and a Y9 sitting are present.
- Let
Y7_mean= mean of all percentiles present in the Y7 sitting (LIT ∪ NUM). - Let
Y9_mean= same for the Y9 sitting. - If
Y9_mean ≥ Y7_mean: add+min(Y9_mean − Y7_mean, 2). - Else: add
−0.75 × (Y7_mean − Y9_mean).
Cap
Let raw_total = sector + icsea + disparity + trend.
- If
raw_NAPb > 90:ceiling = (100 − raw_NAPb) / 2. Ifraw_total > ceiling, clamp toceilingand recordcap_applied = true. - Else
capped_total = raw_total,cap_applied = false.
estATAR
estATAR = clamp(raw_NAPb + capped_total, 0, 99.95)
Step 5 — Standard error
Look up SE from lookup_standard_error keyed on:
n_domains= the number of distinct domains used across all sittings (1–4 on the post-2023 model).has_year9=trueif any sitting hasyear_level === 9.
If no row matches, fall back to a broad SE = 8.
Step 6 — Free-tier conservative floor
The free tier's headline number is the lower 95% CI of the pre-adjustment baseline:
free_conservative = clamp(raw_NAPb − 1.96 × SE, 0, 99.95)
This deliberately excludes the sector, ICSEA, disparity and trend adjustments. The on-report copy calls it a "conservative floor that doesn't account for sector, ATAR subjects chosen, or school attended" — literally true. Paid reveals the point estimate sitting above this floor, plus the full 95% interval around that point.
Step 7 — Rounding and return payload
Every field that represents an ATAR is snapped to the real 0.05 grid. Working values in percentile space (baselines, SEs, adjustment terms) stay at two-decimal precision.
{
percentiles: { [`${domain}_${year_level}`]: number }, // 2 d.p.
bNUM, bLIT, raw_NAPb, // 2 d.p.
adjustments: { sector, icsea, disparity, trend, cap_applied, total },
estATAR, // 0.05 grid
SE, // 2 d.p.
free_conservative, // 0.05 grid
paid_ci_95: [estATAR − 1.96 × SE, estATAR + 1.96 × SE], // 0.05 grid
free_range: [estATAR − SE, estATAR + SE ], // 0.05 grid (legacy ±1σ)
gated_reason: null | 'single_score' | 'needs_num_and_lit',
has_year9: boolean,
n_domains_used: number
}
free_range is kept on stored results for backward compatibility with reports generated
before the free-conservative pivot. New reports surface free_conservative and render
the legacy band only as a fallback.
Entitlement gating
The server always computes the full payload and stores it in calculation.result. The
GET /api/result/[id] endpoint then projects:
entitlement = 'free'→{ percentiles, free_conservative, free_range, gated_reason, has_year9 }.entitlement = 'paid'→ everything above plus{ bNUM, bLIT, raw_NAPb, adjustments, estATAR, SE, paid_ci_95, n_domains_used }.
The DB entitlement column is flipped to 'paid' only by the Stripe webhook on a valid
signed checkout.session.completed whose metadata matches the calculation ID.
Invariants / test coverage
- Pipeline is pure given
(inputs, lookupPercentile, lookupSE); tests inject in-memory stubs so the math is exercised without hitting Postgres. __tests__/pipeline.test.tscovers: gate checks, disparity rule, sector matrix, ICSEA match window, trend bonus (small and capped), trend penalty, cap rule, 99.95 clamp, SE + range widths, every ATAR field snaps to the 0.05 grid, andfree_conservativestrictly belowestATAR.__tests__/icsea.test.tscovers monotonicity and exact values at μ, μ ± 1σ.