// File: composite.ts // Path: packages/scoring/src/composite.ts // Project: AI Risk Index — airiskindex.io // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // // Description: Occupation-level aggregation: importance-weighted means and highly-exposed task share. import { scoreTask } from "./task"; import type { OccupationScores, ScoreBand, TaskInput, TaskScores } from "./types"; import { INDEX_VERSION } from "./version"; /** Substitution score at or above which a task counts as "highly exposed" — METHODOLOGY.md §6. */ export const HIGH_EXPOSURE_THRESHOLD = 70; /** * Aggregate task scores to an occupation: importance-weighted mean, weights * normalized within the occupation — METHODOLOGY.md §6. Pure and deterministic. */ export function scoreOccupation(tasks: readonly TaskInput[]): OccupationScores { if (tasks.length === 0) { throw new RangeError("scoreOccupation: at least one task is required"); } const taskScores = tasks.map(scoreTask); const provided = tasks .map((task) => task.importance) .filter((value): value is number => value !== undefined); for (const value of provided) { if (!Number.isFinite(value) || value < 0) { throw new RangeError(`scoreOccupation: importance must be a non-negative number, got ${value}`); } } // Tasks lacking an importance rating receive the occupation-mean importance (§6). const fallback = provided.length > 0 ? provided.reduce((sum, value) => sum + value, 0) / provided.length : 1; const raw = tasks.map((task) => task.importance ?? fallback); const rawSum = raw.reduce((sum, value) => sum + value, 0); const weights = rawSum > 0 ? raw.map((value) => value / rawSum) : raw.map(() => 1 / raw.length); const aggregate = (pick: (task: TaskScores) => ScoreBand): ScoreBand => { let low = 0; let score = 0; let high = 0; for (const [index, task] of taskScores.entries()) { const weight = weights[index]; const band = pick(task); low += weight * band.low; score += weight * band.score; high += weight * band.high; } return { low, score, high }; }; const highlyExposedTaskShare = taskScores.filter((task) => task.substitution.score >= HIGH_EXPOSURE_THRESHOLD).length / taskScores.length; return { indexVersion: INDEX_VERSION, substitution: aggregate((task) => task.substitution), exposure: aggregate((task) => task.exposure), augmentation: aggregate((task) => task.augmentation), highlyExposedTaskShare, tasks: taskScores, }; }