estimATARStart
Method · the full pipeline

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-2023 conventions / spelling domain 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:

  1. 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'.
  2. 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)). Then bLIT = 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. If raw_total > ceiling, clamp to ceiling and record cap_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 = true if any sitting has year_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.ts covers: 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, and free_conservative strictly below estATAR.
  • __tests__/icsea.test.ts covers monotonicity and exact values at μ, μ ± 1σ.