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: properties.test.ts2// Path: packages/scoring/src/properties.test.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: Property-based tests (fast-check): bounds, monotonicity, determinism, invariances.910import fc from "fast-check";11import { describe, expect, it } from "vitest";12import type { RatingBand, TaskInput, TaskRatings } from "./index";13import { scoreOccupation, scoreTask, WEIGHTS } from "./index";1415const ratingBand = fc16 .tuple(17 fc.integer({ min: 1, max: 5 }),18 fc.integer({ min: 1, max: 5 }),19 fc.integer({ min: 1, max: 5 }),20 )21 .map(([a, b, c]): RatingBand => {22 const [low, mid, high] = [a, b, c].sort((x, y) => x - y);23 return { low, mid, high };24 });2526const taskRatings = fc.record<TaskRatings>({27 automatability: ratingBand,28 feasibility: ratingBand,29 cost_ratio: ratingBand,30 barriers: ratingBand,31 adoption_velocity: ratingBand,32 augmentation: ratingBand,33});3435const taskInput = fc.record<TaskInput>({36 taskId: fc.hexaString({ minLength: 1, maxLength: 8 }),37 importance: fc.double({ min: 0.1, max: 5, noNaN: true }),38 ratings: taskRatings,39});4041const occupationTasks = fc.array(taskInput, { minLength: 1, maxLength: 8 });4243const flat = (rating: number): RatingBand => ({ low: rating, mid: rating, high: rating });4445describe("scoring invariants (property-based)", () => {46 it("weights sum to 1", () => {47 const sum = Object.values(WEIGHTS).reduce((total, weight) => total + weight, 0);48 expect(sum).toBeCloseTo(1, 10);49 });5051 it("all score bands satisfy 0 ≤ low ≤ score ≤ high ≤ 100", () => {52 fc.assert(53 fc.property(occupationTasks, (tasks) => {54 const result = scoreOccupation(tasks);55 const bands = [56 result.substitution,57 result.exposure,58 result.augmentation,59 ...result.tasks.flatMap((task) => [task.substitution, task.exposure, task.augmentation]),60 ];61 for (const band of bands) {62 expect(band.low).toBeGreaterThanOrEqual(-1e-9);63 expect(band.score).toBeGreaterThanOrEqual(band.low - 1e-9);64 expect(band.high).toBeGreaterThanOrEqual(band.score - 1e-9);65 expect(band.high).toBeLessThanOrEqual(100 + 1e-9);66 }67 expect(result.highlyExposedTaskShare).toBeGreaterThanOrEqual(0);68 expect(result.highlyExposedTaskShare).toBeLessThanOrEqual(1);69 }),70 );71 });7273 it("is deterministic", () => {74 fc.assert(75 fc.property(occupationTasks, (tasks) => {76 expect(JSON.stringify(scoreOccupation(tasks))).toBe(JSON.stringify(scoreOccupation(tasks)));77 }),78 );79 });8081 it("substitution increases with automatability and decreases with barriers", () => {82 const flatRating = fc.integer({ min: 1, max: 5 });83 fc.assert(84 fc.property(85 fc.record({86 feasibility: flatRating,87 cost_ratio: flatRating,88 adoption_velocity: flatRating,89 augmentation: flatRating,90 }),91 fc.integer({ min: 1, max: 4 }),92 (rest, rating) => {93 const withDims = (automatability: number, barriers: number): number =>94 scoreTask({95 taskId: "t",96 ratings: {97 automatability: flat(automatability),98 feasibility: flat(rest.feasibility),99 cost_ratio: flat(rest.cost_ratio),100 barriers: flat(barriers),101 adoption_velocity: flat(rest.adoption_velocity),102 augmentation: flat(rest.augmentation),103 },104 }).substitution.score;105 expect(withDims(rating + 1, 3)).toBeGreaterThan(withDims(rating, 3));106 expect(withDims(3, rating + 1)).toBeLessThan(withDims(3, rating));107 },108 ),109 );110 });111112 it("is invariant to uniform scaling of importance weights", () => {113 fc.assert(114 fc.property(occupationTasks, fc.double({ min: 0.5, max: 10, noNaN: true }), (tasks, k) => {115 const scaled = tasks.map((task) => ({116 ...task,117 importance: (task.importance ?? 1) * k,118 }));119 const a = scoreOccupation(tasks);120 const b = scoreOccupation(scaled);121 expect(b.substitution.score).toBeCloseTo(a.substitution.score, 6);122 expect(b.exposure.score).toBeCloseTo(a.exposure.score, 6);123 expect(b.augmentation.score).toBeCloseTo(a.augmentation.score, 6);124 }),125 );126 });127});128