// File: properties.test.ts // Path: packages/scoring/src/properties.test.ts // Project: AI Risk Index — airiskindex.io // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // // Description: Property-based tests (fast-check): bounds, monotonicity, determinism, invariances. import fc from "fast-check"; import { describe, expect, it } from "vitest"; import type { RatingBand, TaskInput, TaskRatings } from "./index"; import { scoreOccupation, scoreTask, WEIGHTS } from "./index"; const ratingBand = fc .tuple( fc.integer({ min: 1, max: 5 }), fc.integer({ min: 1, max: 5 }), fc.integer({ min: 1, max: 5 }), ) .map(([a, b, c]): RatingBand => { const [low, mid, high] = [a, b, c].sort((x, y) => x - y); return { low, mid, high }; }); const taskRatings = fc.record({ automatability: ratingBand, feasibility: ratingBand, cost_ratio: ratingBand, barriers: ratingBand, adoption_velocity: ratingBand, augmentation: ratingBand, }); const taskInput = fc.record({ taskId: fc.hexaString({ minLength: 1, maxLength: 8 }), importance: fc.double({ min: 0.1, max: 5, noNaN: true }), ratings: taskRatings, }); const occupationTasks = fc.array(taskInput, { minLength: 1, maxLength: 8 }); const flat = (rating: number): RatingBand => ({ low: rating, mid: rating, high: rating }); describe("scoring invariants (property-based)", () => { it("weights sum to 1", () => { const sum = Object.values(WEIGHTS).reduce((total, weight) => total + weight, 0); expect(sum).toBeCloseTo(1, 10); }); it("all score bands satisfy 0 ≤ low ≤ score ≤ high ≤ 100", () => { fc.assert( fc.property(occupationTasks, (tasks) => { const result = scoreOccupation(tasks); const bands = [ result.substitution, result.exposure, result.augmentation, ...result.tasks.flatMap((task) => [task.substitution, task.exposure, task.augmentation]), ]; for (const band of bands) { expect(band.low).toBeGreaterThanOrEqual(-1e-9); expect(band.score).toBeGreaterThanOrEqual(band.low - 1e-9); expect(band.high).toBeGreaterThanOrEqual(band.score - 1e-9); expect(band.high).toBeLessThanOrEqual(100 + 1e-9); } expect(result.highlyExposedTaskShare).toBeGreaterThanOrEqual(0); expect(result.highlyExposedTaskShare).toBeLessThanOrEqual(1); }), ); }); it("is deterministic", () => { fc.assert( fc.property(occupationTasks, (tasks) => { expect(JSON.stringify(scoreOccupation(tasks))).toBe(JSON.stringify(scoreOccupation(tasks))); }), ); }); it("substitution increases with automatability and decreases with barriers", () => { const flatRating = fc.integer({ min: 1, max: 5 }); fc.assert( fc.property( fc.record({ feasibility: flatRating, cost_ratio: flatRating, adoption_velocity: flatRating, augmentation: flatRating, }), fc.integer({ min: 1, max: 4 }), (rest, rating) => { const withDims = (automatability: number, barriers: number): number => scoreTask({ taskId: "t", ratings: { automatability: flat(automatability), feasibility: flat(rest.feasibility), cost_ratio: flat(rest.cost_ratio), barriers: flat(barriers), adoption_velocity: flat(rest.adoption_velocity), augmentation: flat(rest.augmentation), }, }).substitution.score; expect(withDims(rating + 1, 3)).toBeGreaterThan(withDims(rating, 3)); expect(withDims(3, rating + 1)).toBeLessThan(withDims(3, rating)); }, ), ); }); it("is invariant to uniform scaling of importance weights", () => { fc.assert( fc.property(occupationTasks, fc.double({ min: 0.5, max: 10, noNaN: true }), (tasks, k) => { const scaled = tasks.map((task) => ({ ...task, importance: (task.importance ?? 1) * k, })); const a = scoreOccupation(tasks); const b = scoreOccupation(scaled); expect(b.substitution.score).toBeCloseTo(a.substitution.score, 6); expect(b.exposure.score).toBeCloseTo(a.exposure.score, 6); expect(b.augmentation.score).toBeCloseTo(a.augmentation.score, 6); }), ); }); });