spb/airiskindex Public
The most methodologically rigorous, fully transparent AI job-exposure index.
TypeScript 88%
Python 6.1%
SQL 2.7%
CSS 1.2%
JavaScript 0.9%
Shell 0.8%
1// File: task.ts2// Path: packages/scoring/src/task.ts3// Project: AI Risk Index — airiskindex.io4// Author: Simon-Pierre Boucher5// Contact: contact@spboucher.ai6// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.7//8// Description: Task-level scoring: rating normalization, orientation, weighted score bands.910import type { RatingBand, ScoreBand, TaskInput, TaskScores } from "./types";11import {12 DIMENSIONS,13 type DimensionKey,14 EXPOSURE_DIMENSIONS,15 INVERTED_DIMENSIONS,16 WEIGHTS,17} from "./weights";1819function assertRating(value: number, context: string): void {20 if (!Number.isFinite(value) || value < 1 || value > 5) {21 throw new RangeError(`${context}: rating must be within [1, 5], got ${value}`);22 }23}2425function assertBand(band: RatingBand, context: string): void {26 assertRating(band.low, `${context}.low`);27 assertRating(band.mid, `${context}.mid`);28 assertRating(band.high, `${context}.high`);29 if (band.low > band.mid || band.mid > band.high) {30 throw new RangeError(`${context}: band must satisfy low ≤ mid ≤ high`);31 }32}3334interface PressureBand {35 low: number;36 mid: number;37 high: number;38}3940/** METHODOLOGY.md §5: rating (1–5) → substitution pressure in [0, 1]. */41export function pressure(dimension: DimensionKey, rating: number): number {42 const p = (rating - 1) / 4;43 return INVERTED_DIMENSIONS.has(dimension) ? 1 - p : p;44}4546/**47 * Pressure bounds for a rating band. For inverted dimensions the rating's48 * HIGH bound minimizes pressure, so the bounds swap — this is what keeps49 * low ≤ score ≤ high true for every dimension orientation.50 */51function pressureBand(dimension: DimensionKey, band: RatingBand): PressureBand {52 const inverted = INVERTED_DIMENSIONS.has(dimension);53 return {54 low: pressure(dimension, inverted ? band.high : band.low),55 mid: pressure(dimension, band.mid),56 high: pressure(dimension, inverted ? band.low : band.high),57 };58}5960function weightedScore(61 pressures: ReadonlyMap<DimensionKey, PressureBand>,62 dimensions: readonly DimensionKey[],63 bound: keyof PressureBand,64): number {65 let total = 0;66 let weightSum = 0;67 for (const dimension of dimensions) {68 const band = pressures.get(dimension);69 if (!band) throw new RangeError(`missing pressure for dimension "${dimension}"`);70 total += WEIGHTS[dimension] * band[bound];71 weightSum += WEIGHTS[dimension];72 }73 return (100 * total) / weightSum;74}7576function toScoreBand(compute: (bound: keyof PressureBand) => number): ScoreBand {77 return { low: compute("low"), score: compute("mid"), high: compute("high") };78}7980/** Score a single task — formulas in METHODOLOGY.md §5. Pure and deterministic. */81export function scoreTask(input: TaskInput): TaskScores {82 const pressures = new Map<DimensionKey, PressureBand>();83 for (const dimension of DIMENSIONS) {84 const band = input.ratings[dimension];85 if (!band) {86 throw new RangeError(`task ${input.taskId}: missing rating for dimension "${dimension}"`);87 }88 assertBand(band, `task ${input.taskId}.${dimension}`);89 pressures.set(dimension, pressureBand(dimension, band));90 }9192 const augmentation = input.ratings.augmentation;93 if (!augmentation) {94 throw new RangeError(`task ${input.taskId}: missing augmentation rating`);95 }96 assertBand(augmentation, `task ${input.taskId}.augmentation`);9798 return {99 taskId: input.taskId,100 substitution: toScoreBand((bound) => weightedScore(pressures, DIMENSIONS, bound)),101 exposure: toScoreBand((bound) => weightedScore(pressures, EXPOSURE_DIMENSIONS, bound)),102 augmentation: {103 low: (100 * (augmentation.low - 1)) / 4,104 score: (100 * (augmentation.mid - 1)) / 4,105 high: (100 * (augmentation.high - 1)) / 4,106 },107 };108}109