Skip to main content

The scoring pipeline

All of the modelling lives in packages/core: pure functions over plain data, with no I/O. The API calls it to finalise datasets; the web app calls it to re-score rows for interactive views and exports. Every function takes a compiled Reference (see Reference data), so a dataset is always scored with the reference data version it's pinned to.

EmployeeInput rows ─┐
├─ scoreDataset(ref, rows, titleClassifications, settings) ──▶ ScoredEmployee[]
title → TitleClassification ┘ │
buildDatasetSummary(ref, scored, titles, {pack, horizonStart})
▼
DatasetSummary
(headline, rollups, buckets, scenarios, report, classification
coverage, org, Copilot fit counts, benchmark aggregates, methodology)

Module map​

ModuleResponsibility
reference/types.ts, reference/schema.ts, reference/compile.ts, reference/builtin/The ReferenceData shape, its validation, compilation into a Reference, and the built-in version 1.
classification.tsnormaliseTitle, the precedence ladder (resolveTitleClassification), classificationFromSoc, classificationFromTaxonomy, classificationByRules, classificationByPack, needsReview.
taxonomy.tsclassifyRole: ordered title rules, first match wins, division-aware.
packs.tsmatchPackOverlay: an industry pack's overlay rules.
exposure.ts, exposure-anchor.tsThe rubric, and exposure anchored to AIOE + AEI (scoreExposureAnchored, scoreExposureBySoc).
salary.tsRegion resolution, location factors, ASHE medians, proxies and employer cost.
pipeline.tsscoreEmployee, scoreEmployeeFromSoc, scoreWithClassification, scoreDataset.
scenarios.tsemployeeSaving, modelScenario(s), rollupBy.
report.tsThe report models: benchmark alignment, value chain, S-curve, reinvestment, investment, offshore/GCC, top roles.
summary.tsbuildDatasetSummary, computeHeadline, exposure buckets, classification coverage, benchmark aggregates.
org.tsThe org graph, spans, layers and roll-ups.
copilot.ts, rollout.tsThe Copilot business case, allocation and rollout plans.
benchmarks.tsCross-engagement medians and suppression.
workbook.tsThe finance workbook as a pure model of sheets, cells and formulas.
provenance.ts, metric-provenance.ts, citations.tsProvenance and citation validation. See Provenance.
prompts.tsPrompt definitions, built-in prompts, rendering and validation. See Prompts.
schemas.tszod request schemas and response types shared with the API and web.
numeric.ts, format.tsGuards (clamping, finite checks, limits) and formatting (gbp, pct, num, dates).

1. Classification​

Classification happens per distinct title on the server (see Upload and classification) and produces a TitleClassification:

interface TitleClassification {
roleCategory: string;
roleSubcategory: string;
usSoc2018: string | null;
ukSoc2020: string | null;
confidence: number | null; // 0–1; null for deterministic sources
source: "override" | "pack" | "cache" | "ai" | "rules";
rationale: string | null;
}

The ladder is: override → industry pack → shared cache → AI → rules. A SOC pick (from the cache, AI or a SOC override) is accepted only if both codes are in the reference catalogues (isValidSocPick); the labels become the US SOC major-group title and the occupation title. classificationByRules returns confidence 0 for Other / Uncategorised (so it's flagged) and null otherwise. needsReview flags AI and cache picks below classification.reviewThreshold, and uncategorised rules; overrides and pack rules never.

normaliseTitle (NFKC, lower-case, unify dashes, collapse whitespace, trim surrounding punctuation) is the key for overrides and the cache.

2. Scoring an employee​

scoreWithClassification(ref, emp, cls, settings) picks one of three paths:

ClassificationExposureSalaryLabels
rules (or none)Re-run classifyRole per employee (rules can depend on division, compared ignoring case and surrounding spaces), then the role's anchor or rubricThe role's UK codeThe taxonomy role
Has a SOC pair (AI, cache, pack, SOC override)scoreExposureBySoc: the US code's AIOE percentileapplySalaryBySoc: the UK codeFrom the classification
Taxonomy override (no codes)The role's anchor or rubricThe role's UK codeThe chosen role

Exposure​

For an AIOE percentile p (0–100):

augmentation% = round( (augFloor + (augCeil − augFloor) × p/100) × (1 − realismDiscount) )
automation% = round( augmentation% × aeiAutoShare )
horizon = "now" if p ≥ horizonNowFrom, "near" if p ≥ horizonNearFrom, else "medium"

With the built-in data: augFloor 30, augCeil 85, aeiAutoShare 0.486, cut-offs 66 and 33. The realism discount is clamped to [0, 0.99).

  • A taxonomy role with an anchor uses the anchor's percentile (measured provenance).
  • A SOC-coded role uses occupations.aioe[usSoc2018]; if the occupation has no AIOE row, the default rubric is used and labelled an assumption.
  • A role with no anchor uses its rubric (an assumption).
  • Both rubric fallbacks apply the same realism discount: round(rubric% × (1 − realismDiscount)) for augmentation and automation alike.
  • A SOC pick below the review threshold gets confidence: "low" and a note in its rationale.

Salary​

region = explicit country alias match ?? first region with a keyword in location ?? baseline
factor = settings.locationFactors[region] ?? region default ?? baseline factor
salary = client baseSalary (if > 0 and in range) else round(ukMedian × factor)
employerCost = round(salary × employerCostMultiplier)

ukMedian is the ASHE median for the UK code, else its proxy median, else fallbackMedian. An unknown UK code on the SOC path falls back to ukDefaultCode. Factors never apply to client-supplied salaries. Amounts are clamped (numeric.ts) so totals fit the database's integer columns.

3. Scenarios​

employeeSaving(emp, p) =
employerCost × ( automation/100 × p.automationRealised
+ max(0, augmentation − automation)/100 × p.augmentationProductivity )

modelScenario sums it, with savingPctOfCogs and fteEquivalent = round(grossSaving ÷ average employer cost). The scenarios come from ref.data.scenarios, which is validated to be exactly conservative, moderate and aggressive, in that order (the finance workbook lays them out by position).

Because savings are linear in employer cost, exposureBuckets in the summary lets the Overview's scenario sandbox reproduce any custom scenario exactly without rows.

4. The report models​

buildReportModel(ref, employees, { lifecycle, horizonStart, scenarios? }) (pass scenarios when they're already modelled, as buildDatasetSummary does):

ModelHow
benchmarkAverage exposure per occupation group (by taxonomy category or SOC major) beside the group's theoretical and observed coverage from Massenkoff & McCrory, "Labor market impacts of AI" (Anthropic, 2026; source aeiLabourMarket).
lifecycleWith a pack lifecycle: the pack's if it's meaningful (covers ≥ meaningfulShare of people), else the generic chain. Without one: the auto lifecycle (claims) if meaningful, else the generic chain (each category → one phase, always meaningful). Phase FTE, cost and moderate saving; splits by scenario. This is the value chain the report shows without AI, and the one every export uses.
scurveEach scenario's FTE × the S-curve percentages, per half-year from horizonStart (the half-year the dataset was created).
reinvestmentComponents and totals as ranges of the moderate gross saving; net = gross − reinvestment.
investmentPer scenario, the investment range = the scenario's gross annual saving ÷ the ROI band's max and min. The midpoint of the moderate range is split by the investment split (10-20-70) and phased by the four investment phases, which run 6, 12, 12 and 18 months from horizonStart (investmentPeriods: contiguous and four years in all, for example "H1 2026", "H2 2026–H1 2027", "H2 2027–H1 2028", "H2 2028–H2 2029", or "H2 2026", "2027", "2028", "H1 2029–H1 2030"); the payback months are quoted from the reference data.
gccPeople already offshore (factor below offshoreBelow), and migration candidates (migratable SOC major or subcategory, factor at or above onshoreFrom) re-priced at the cheapest offshore factor present (or defaultOffshoreFactor).
topSubcategories, bySubcategoryRoles ranked by moderate saving: every role in bySubcategory (no cut-off), and its first ten as topSubcategories.

computeLifecycleFromAggregates recomputes a value chain from bySubcategory alone, which is how an AI-mapped chain is costed without fetching rows. For an AI-proposed chain, normaliseLifecycleDefinition lists the roles the model didn't place in evenlySpread (they're spread evenly across the phases), and the costed model reports mapping (mappedRoles, mappedHeadcount, evenlySpreadRoles, evenlySpreadHeadcount), so spread roles are never presented as mapped.

5. The summary​

buildDatasetSummary produces everything the report, exports, comparisons and benchmarks need from one pass: the headline, rollups by category, division, business and tenant, exposure buckets, scenarios, the report, classification coverage (by source, and review counts), the org structure, benchmark aggregates, and a methodology block recording the reference version, the anchor band and the pack. The server stores it in datasets.summary; after a purge it's all that remains.

6. Organisation​

buildOrgGraph(rows) links each person to their manager by employee ID (the first row wins for duplicate IDs), counts managers missing from the list (dangling references, which become roots) and breaks cycles. summariseOrgStructure gives managers, average and median span, span bands, layers, narrow spans (≤ org.narrowSpan) and wide spans (≥ org.wideSpan). subtreeRollups gives headcount, cost, average automation and managers for every reporting line.

7. Copilot and rollout​

copilotFitFor(ref, e, pack): the pack's high/low lists first; then, for SOC-coded rows, fitBySocMajor[major]; for taxonomy rows, the no-benefit, high and low lists; else defaultTier.

benefit = employerCost × valueBase × augmentationProductivity × fitShare[tier]
// valueBase: (augmentation − automation)/100 for "headroom" (default), augmentation/100 for "augmentation"

computeCopilotCase totals by tier with licence cost (licenceMonthlyCost × 12 per licence), ROI and payback; allocateCopilotLicences(model, budget) fills the highest benefit per licence first. Payback is 12 × cost ÷ benefit months; it is null when there's no payback within COPILOT_PAYBACK_HORIZON_MONTHS (120): when the benefit is zero or less, or when it would take longer, in which case paybackBeyondHorizon is true. No capped figure is reported.

computeRollout(ref, employees, config, pack) processes waves in order: each wave's cohorts select people (topLevels, reportingLine, attribute, people, fitTier, managers, with exclusions); anyone already covered (existing holders or earlier waves) is skipped; the rest are sorted by benefit (ties by employee ID) and capped. Results are split by tenant (Unassigned when blank) and valued by persona.

topLevels(graph, n) counts levels from the roots that have reports, so people with no manager and no reports (a flat list, or someone whose manager isn't in the file) are never "leadership". defaultRolloutPlan(ref, org?) takes the graph or the summary's org structure: with reporting lines it starts with the top three levels, then high-fit roles capped at 500; without them it is a single high-fit wave capped at 500. rolloutNeedsHierarchyWarning(config, org) is true when a plan selects by reporting line (topLevels, reportingLine or managers) but the list has none, and the Rollout tab then shows a notice.

8. The finance workbook​

buildWorkbookModel(ref, input) (workbook.ts) describes all ten sheets, cells and formulas as data, so it's unit-tested without a spreadsheet library; apps/web/src/exports/workbook.ts renders it with ExcelJS. Every saving is a live formula that references the Assumptions sheet's cells (ASSUMPTION_CELLS), and workbook.test.ts checks the formulas reproduce the platform's scenarios exactly.

Group sheets aggregate Raw Data with SUMPRODUCT(--EXACT(range, key), …), not COUNTIF or SUMIF. Those match case-insensitively and treat * ? ~ and a leading < > = as wildcards or operators, so "Sales" and "SALES", or "Ops*", would be pooled in Excel but not on the platform. The JavaScript grouping is exact too, keyed by the Raw Data value (a blank shows as "—" and matches ""). Locations & GCC has one row per country and location factor, so each row carries the factor its people were priced at, and its note quotes the reference data's gcc.offshoreBelow.

Invariants​

  • Core is pure: no fetch, no Date.now() in model code paths (the horizon start is passed in), no DOM or Node APIs.
  • Anything shown to a user is traceable to a MetricKey with provenance.
  • The server and browser must get the same numbers: never branch on runtime, and keep every constant in reference data.