SPB Git

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%
4.4 KB · 128 lines typescript
Raw Blame History
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