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
| Module | Responsibility |
|---|---|
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.ts | normaliseTitle, the precedence ladder (resolveTitleClassification), classificationFromSoc, classificationFromTaxonomy, classificationByRules, classificationByPack, needsReview. |
taxonomy.ts | classifyRole: ordered title rules, first match wins, division-aware. |
packs.ts | matchPackOverlay: an industry pack's overlay rules. |
exposure.ts, exposure-anchor.ts | The rubric, and exposure anchored to AIOE + AEI (scoreExposureAnchored, scoreExposureBySoc). |
salary.ts | Region resolution, location factors, ASHE medians, proxies and employer cost. |
pipeline.ts | scoreEmployee, scoreEmployeeFromSoc, scoreWithClassification, scoreDataset. |
scenarios.ts | employeeSaving, modelScenario(s), rollupBy. |
report.ts | The report models: benchmark alignment, value chain, S-curve, reinvestment, investment, offshore/GCC, top roles. |
summary.ts | buildDatasetSummary, computeHeadline, exposure buckets, classification coverage, benchmark aggregates. |
org.ts | The org graph, spans, layers and roll-ups. |
copilot.ts, rollout.ts | The Copilot business case, allocation and rollout plans. |
benchmarks.ts | Cross-engagement medians and suppression. |
workbook.ts | The finance workbook as a pure model of sheets, cells and formulas. |
provenance.ts, metric-provenance.ts, citations.ts | Provenance and citation validation. See Provenance. |
prompts.ts | Prompt definitions, built-in prompts, rendering and validation. See Prompts. |
schemas.ts | zod request schemas and response types shared with the API and web. |
numeric.ts, format.ts | Guards (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:
| Classification | Exposure | Salary | Labels |
|---|---|---|---|
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 rubric | The role's UK code | The taxonomy role |
| Has a SOC pair (AI, cache, pack, SOC override) | scoreExposureBySoc: the US code's AIOE percentile | applySalaryBySoc: the UK code | From the classification |
| Taxonomy override (no codes) | The role's anchor or rubric | The role's UK code | The 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
anchoruses 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):
| Model | How |
|---|---|
benchmark | Average 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). |
lifecycle | With 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. |
scurve | Each scenario's FTE × the S-curve percentages, per half-year from horizonStart (the half-year the dataset was created). |
reinvestment | Components and totals as ranges of the moderate gross saving; net = gross − reinvestment. |
investment | Per 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. |
gcc | People 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, bySubcategory | Roles 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, noDate.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
MetricKeywith provenance. - The server and browser must get the same numbers: never branch on runtime, and keep every constant in reference data.