import type { Triangle, TriangleKind } from "@actuarial-ts/core"; import { ReservingError, triangleFromGrid } from "@actuarial-ts/core"; /** * Long-format ("tidy") triangle assembly: one row per observed * (origin, age) cell, pivoted into the origins x ages grid the engine uses. * * - Origins are sorted lexicographically (matching triangle label order, * e.g. "2019" < "2020", "2021Q1" < "2021Q3"). * - Ages are sorted numerically ascending. * - Pairs absent from the input are null (not yet observable / missing). * - Duplicate (origin, age) pairs are ambiguous and throw * ReservingError("SHAPE", ...), even when the values agree. */ export interface LongFormatRow { origin: string; /** Development age in months. */ age: number; value: number | null; } export interface TriangleFromLongFormatOptions { kind: TriangleKind; } /** Pivots long-format rows into a Triangle via core's triangleFromGrid. */ export function triangleFromLongFormat( rows: LongFormatRow[], opts: TriangleFromLongFormatOptions, ): Triangle { const origins = [...new Set(rows.map((r) => r.origin))].sort(); const ages = [...new Set(rows.map((r) => r.age))].sort((a, b) => a - b); const originIndex = new Map(origins.map((o, i) => [o, i] as const)); const ageIndex = new Map(ages.map((a, j) => [a, j] as const)); const values: (number | null)[][] = origins.map(() => ages.map(() => null)); const seen = new Set(); for (const row of rows) { const key = `${row.origin}${row.age}`; if (seen.has(key)) { throw new ReservingError( "SHAPE", `Duplicate (origin, age) pair: ("${row.origin}", ${row.age})`, ); } seen.add(key); values[originIndex.get(row.origin)!]![ageIndex.get(row.age)!] = row.value; } return triangleFromGrid(opts.kind, origins, ages, values); }