// File: task.ts // Path: packages/scoring/src/task.ts // Project: AI Risk Index — airiskindex.io // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // // Description: Task-level scoring: rating normalization, orientation, weighted score bands. import type { RatingBand, ScoreBand, TaskInput, TaskScores } from "./types"; import { DIMENSIONS, type DimensionKey, EXPOSURE_DIMENSIONS, INVERTED_DIMENSIONS, WEIGHTS, } from "./weights"; function assertRating(value: number, context: string): void { if (!Number.isFinite(value) || value < 1 || value > 5) { throw new RangeError(`${context}: rating must be within [1, 5], got ${value}`); } } function assertBand(band: RatingBand, context: string): void { assertRating(band.low, `${context}.low`); assertRating(band.mid, `${context}.mid`); assertRating(band.high, `${context}.high`); if (band.low > band.mid || band.mid > band.high) { throw new RangeError(`${context}: band must satisfy low ≤ mid ≤ high`); } } interface PressureBand { low: number; mid: number; high: number; } /** METHODOLOGY.md §5: rating (1–5) → substitution pressure in [0, 1]. */ export function pressure(dimension: DimensionKey, rating: number): number { const p = (rating - 1) / 4; return INVERTED_DIMENSIONS.has(dimension) ? 1 - p : p; } /** * Pressure bounds for a rating band. For inverted dimensions the rating's * HIGH bound minimizes pressure, so the bounds swap — this is what keeps * low ≤ score ≤ high true for every dimension orientation. */ function pressureBand(dimension: DimensionKey, band: RatingBand): PressureBand { const inverted = INVERTED_DIMENSIONS.has(dimension); return { low: pressure(dimension, inverted ? band.high : band.low), mid: pressure(dimension, band.mid), high: pressure(dimension, inverted ? band.low : band.high), }; } function weightedScore( pressures: ReadonlyMap, dimensions: readonly DimensionKey[], bound: keyof PressureBand, ): number { let total = 0; let weightSum = 0; for (const dimension of dimensions) { const band = pressures.get(dimension); if (!band) throw new RangeError(`missing pressure for dimension "${dimension}"`); total += WEIGHTS[dimension] * band[bound]; weightSum += WEIGHTS[dimension]; } return (100 * total) / weightSum; } function toScoreBand(compute: (bound: keyof PressureBand) => number): ScoreBand { return { low: compute("low"), score: compute("mid"), high: compute("high") }; } /** Score a single task — formulas in METHODOLOGY.md §5. Pure and deterministic. */ export function scoreTask(input: TaskInput): TaskScores { const pressures = new Map(); for (const dimension of DIMENSIONS) { const band = input.ratings[dimension]; if (!band) { throw new RangeError(`task ${input.taskId}: missing rating for dimension "${dimension}"`); } assertBand(band, `task ${input.taskId}.${dimension}`); pressures.set(dimension, pressureBand(dimension, band)); } const augmentation = input.ratings.augmentation; if (!augmentation) { throw new RangeError(`task ${input.taskId}: missing augmentation rating`); } assertBand(augmentation, `task ${input.taskId}.augmentation`); return { taskId: input.taskId, substitution: toScoreBand((bound) => weightedScore(pressures, DIMENSIONS, bound)), exposure: toScoreBand((bound) => weightedScore(pressures, EXPOSURE_DIMENSIONS, bound)), augmentation: { low: (100 * (augmentation.low - 1)) / 4, score: (100 * (augmentation.mid - 1)) / 4, high: (100 * (augmentation.high - 1)) / 4, }, }; }