@actuarial-ts/core 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -226
- package/dist/casualtyDiagnostics.d.ts +19 -49
- package/dist/casualtyDiagnostics.d.ts.map +1 -1
- package/dist/casualtyDiagnostics.js +84 -116
- package/dist/casualtyDiagnostics.js.map +1 -1
- package/dist/diagnosticAggregation.d.ts +29 -0
- package/dist/diagnosticAggregation.d.ts.map +1 -0
- package/dist/diagnosticAggregation.js +50 -0
- package/dist/diagnosticAggregation.js.map +1 -0
- package/dist/diagnosticDefinitions.d.ts +286 -0
- package/dist/diagnosticDefinitions.d.ts.map +1 -0
- package/dist/diagnosticDefinitions.js +1026 -0
- package/dist/diagnosticDefinitions.js.map +1 -0
- package/dist/diagnosticDerivations.d.ts +12 -0
- package/dist/diagnosticDerivations.d.ts.map +1 -0
- package/dist/diagnosticDerivations.js +80 -0
- package/dist/diagnosticDerivations.js.map +1 -0
- package/dist/diagnosticExposure.d.ts +51 -0
- package/dist/diagnosticExposure.d.ts.map +1 -0
- package/dist/diagnosticExposure.js +65 -0
- package/dist/diagnosticExposure.js.map +1 -0
- package/dist/diagnosticExpressions.d.ts +56 -0
- package/dist/diagnosticExpressions.d.ts.map +1 -0
- package/dist/diagnosticExpressions.js +104 -0
- package/dist/diagnosticExpressions.js.map +1 -0
- package/dist/diagnosticFormulas.d.ts +195 -0
- package/dist/diagnosticFormulas.d.ts.map +1 -0
- package/dist/diagnosticFormulas.js +85 -0
- package/dist/diagnosticFormulas.js.map +1 -0
- package/dist/diagnosticIdentity.d.ts +237 -0
- package/dist/diagnosticIdentity.d.ts.map +1 -0
- package/dist/diagnosticIdentity.js +454 -0
- package/dist/diagnosticIdentity.js.map +1 -0
- package/dist/diagnosticPeriods.d.ts +16 -0
- package/dist/diagnosticPeriods.d.ts.map +1 -0
- package/dist/diagnosticPeriods.js +98 -0
- package/dist/diagnosticPeriods.js.map +1 -0
- package/dist/diagnosticPreparation.d.ts +98 -0
- package/dist/diagnosticPreparation.d.ts.map +1 -0
- package/dist/diagnosticPreparation.js +169 -0
- package/dist/diagnosticPreparation.js.map +1 -0
- package/dist/diagnosticReview.d.ts +44 -0
- package/dist/diagnosticReview.d.ts.map +1 -0
- package/dist/diagnosticReview.js +84 -0
- package/dist/diagnosticReview.js.map +1 -0
- package/dist/diagnosticRules.d.ts +32 -0
- package/dist/diagnosticRules.d.ts.map +1 -0
- package/dist/diagnosticRules.js +32 -0
- package/dist/diagnosticRules.js.map +1 -0
- package/dist/diagnosticRunner.d.ts +85 -0
- package/dist/diagnosticRunner.d.ts.map +1 -0
- package/dist/diagnosticRunner.js +117 -0
- package/dist/diagnosticRunner.js.map +1 -0
- package/dist/index.d.ts +24 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +114 -0
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +2 -2
- package/src/casualtyDiagnostics.ts +79 -189
- package/src/diagnosticAggregation.ts +79 -0
- package/src/diagnosticDefinitions.ts +1375 -0
- package/src/diagnosticDerivations.ts +89 -0
- package/src/diagnosticExposure.ts +91 -0
- package/src/diagnosticExpressions.ts +173 -0
- package/src/diagnosticFormulas.ts +150 -0
- package/src/diagnosticIdentity.ts +767 -0
- package/src/diagnosticPeriods.ts +117 -0
- package/src/diagnosticPreparation.ts +233 -0
- package/src/diagnosticReview.ts +91 -0
- package/src/diagnosticRules.ts +69 -0
- package/src/diagnosticRunner.ts +110 -0
- package/src/index.ts +156 -1
- package/src/types.ts +161 -0
- package/src/version.ts +2 -0
- package/dist/metricDiagnostics.d.ts +0 -212
- package/dist/metricDiagnostics.d.ts.map +0 -1
- package/dist/metricDiagnostics.js +0 -627
- package/dist/metricDiagnostics.js.map +0 -1
- package/src/metricDiagnostics.ts +0 -876
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertCompiledDiagnosticDefinition,
|
|
3
|
+
getCompiledDiagnosticDefinitionInternals,
|
|
4
|
+
type CompiledDiagnosticDefinition,
|
|
5
|
+
} from "./diagnosticDefinitions.js";
|
|
6
|
+
import type { DiagnosticClaimExpression } from "./diagnosticExpressions.js";
|
|
7
|
+
import { DiagnosticValidationError, type DiagnosticValidationIssue } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export type DiagnosticMeasureValues = Readonly<Record<string, number | null>>;
|
|
10
|
+
|
|
11
|
+
export type DiagnosticRowWithDerivedMeasures<TRow extends { measures: DiagnosticMeasureValues }> =
|
|
12
|
+
Omit<TRow, "measures"> & { readonly measures: DiagnosticMeasureValues };
|
|
13
|
+
|
|
14
|
+
function evaluateClaimExpression(
|
|
15
|
+
expression: DiagnosticClaimExpression,
|
|
16
|
+
measures: Readonly<Record<string, number | null>>,
|
|
17
|
+
): number | null {
|
|
18
|
+
if (expression.op === "measure") {
|
|
19
|
+
const value = measures[expression.measureId];
|
|
20
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
21
|
+
}
|
|
22
|
+
if (expression.op === "claim-layer") {
|
|
23
|
+
const value = measures[expression.measureId];
|
|
24
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
25
|
+
const excess = Math.max(value - expression.attachment, 0);
|
|
26
|
+
const result = expression.limit === null ? excess : Math.min(excess, expression.limit);
|
|
27
|
+
return Number.isFinite(result) ? (Object.is(result, -0) ? 0 : result) : null;
|
|
28
|
+
}
|
|
29
|
+
if (expression.op === "subtract") {
|
|
30
|
+
const left = evaluateClaimExpression(expression.left, measures);
|
|
31
|
+
const right = evaluateClaimExpression(expression.right, measures);
|
|
32
|
+
if (left === null || right === null) return null;
|
|
33
|
+
const result = left - right;
|
|
34
|
+
return Number.isFinite(result) ? (Object.is(result, -0) ? 0 : result) : null;
|
|
35
|
+
}
|
|
36
|
+
let sum = 0;
|
|
37
|
+
let correction = 0;
|
|
38
|
+
for (const term of expression.terms) {
|
|
39
|
+
const value = evaluateClaimExpression(term, measures);
|
|
40
|
+
if (value === null) return null;
|
|
41
|
+
const next = sum + value;
|
|
42
|
+
correction += Math.abs(sum) >= Math.abs(value) ? (sum - next) + value : (value - next) + sum;
|
|
43
|
+
sum = next;
|
|
44
|
+
if (!Number.isFinite(sum) || !Number.isFinite(correction)) return null;
|
|
45
|
+
}
|
|
46
|
+
const result = sum + correction;
|
|
47
|
+
return Number.isFinite(result) ? (Object.is(result, -0) ? 0 : result) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Materializes compiler-approved claim-level measures without mutating caller rows. */
|
|
51
|
+
export function deriveDiagnosticClaimMeasures<TRow extends { measures: DiagnosticMeasureValues }>(
|
|
52
|
+
rows: readonly TRow[],
|
|
53
|
+
definition: CompiledDiagnosticDefinition,
|
|
54
|
+
): readonly DiagnosticRowWithDerivedMeasures<TRow>[] {
|
|
55
|
+
assertCompiledDiagnosticDefinition(definition);
|
|
56
|
+
const internals = getCompiledDiagnosticDefinitionInternals(definition);
|
|
57
|
+
const issues: DiagnosticValidationIssue[] = [];
|
|
58
|
+
if (definition.definition.lossRowGrain !== "claim") {
|
|
59
|
+
issues.push({ domain: "input", code: "invalid-input-relationship", path: "$.definition.lossRowGrain", message: "Claim derivation requires a claim-grain definition" });
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(rows)) {
|
|
62
|
+
issues.push({ domain: "input", code: "invalid-type", path: "$.rows", message: "Claim rows must be an array" });
|
|
63
|
+
} else {
|
|
64
|
+
rows.forEach((row, rowIndex) => {
|
|
65
|
+
const measures = row?.measures;
|
|
66
|
+
if (measures === null || typeof measures !== "object" || Array.isArray(measures)) {
|
|
67
|
+
issues.push({ domain: "input", code: "invalid-type", path: `$.rows[${rowIndex}].measures`, message: "Row measures must be a plain record" });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const [measureId, value] of Object.entries(measures)) {
|
|
71
|
+
const measure = internals.measuresById.get(measureId);
|
|
72
|
+
const path = `$.rows[${rowIndex}].measures[${JSON.stringify(measureId)}]`;
|
|
73
|
+
if (!measure) issues.push({ domain: "input", code: "unknown-reference", path, message: `Unknown measure ${measureId}` });
|
|
74
|
+
else if (measure.source !== "loss") issues.push({ domain: "input", code: "invalid-input-relationship", path, message: `Caller rows cannot supply ${measure.source} measure ${measureId}` });
|
|
75
|
+
if (value !== null && typeof value !== "number") issues.push({ domain: "input", code: "invalid-type", path, message: "Measure value must be a number or null" });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (issues.length > 0) throw new DiagnosticValidationError(issues);
|
|
80
|
+
|
|
81
|
+
const plan = [...internals.derivationsByOutputMeasureId.values()];
|
|
82
|
+
return Object.freeze(rows.map((row) => {
|
|
83
|
+
const measures: Record<string, number | null> = { ...row.measures };
|
|
84
|
+
for (const derivation of plan) {
|
|
85
|
+
measures[derivation.outputMeasureId] = evaluateClaimExpression(derivation.expression, measures);
|
|
86
|
+
}
|
|
87
|
+
return Object.freeze({ ...row, measures: Object.freeze(measures) }) as DiagnosticRowWithDerivedMeasures<TRow>;
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { DiagnosticSourceLocation } from "./diagnosticDefinitions.js";
|
|
2
|
+
|
|
3
|
+
export interface DiagnosticExposureObservation {
|
|
4
|
+
readonly key: string;
|
|
5
|
+
readonly sourceGroup: string;
|
|
6
|
+
readonly origin: string;
|
|
7
|
+
readonly valuation?: string;
|
|
8
|
+
readonly measureId: string;
|
|
9
|
+
readonly value: number | null;
|
|
10
|
+
readonly complete: boolean;
|
|
11
|
+
readonly source?: DiagnosticSourceLocation;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type DiagnosticAuditedNumericValue =
|
|
15
|
+
| { readonly status: "observed"; readonly value: number }
|
|
16
|
+
| { readonly status: "missing"; readonly value: null }
|
|
17
|
+
| { readonly status: "non-finite"; readonly value: null; readonly nonFiniteKind: "nan" | "positive-infinity" | "negative-infinity" };
|
|
18
|
+
|
|
19
|
+
export interface DiagnosticExposureAuditObservation {
|
|
20
|
+
readonly sourceGroup: string;
|
|
21
|
+
readonly origin: string;
|
|
22
|
+
readonly valuation?: string;
|
|
23
|
+
readonly value: DiagnosticAuditedNumericValue;
|
|
24
|
+
readonly complete: boolean;
|
|
25
|
+
readonly source?: DiagnosticSourceLocation;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ReconciledDiagnosticExposure =
|
|
29
|
+
| { readonly measureId: string; readonly key: string; readonly status: "valid"; readonly sourceGroup: string; readonly origin: string; readonly valuation?: string; readonly value: number; readonly deduplicated: number; readonly sources: readonly DiagnosticSourceLocation[] }
|
|
30
|
+
| { readonly measureId: string; readonly key: string; readonly status: "invalid"; readonly issues: readonly ("missing" | "incomplete" | "non-finite" | "duplicate" | "conflict")[]; readonly value: null; readonly observations: readonly DiagnosticExposureAuditObservation[] };
|
|
31
|
+
|
|
32
|
+
export function auditDiagnosticNumber(value: number | null): DiagnosticAuditedNumericValue {
|
|
33
|
+
if (value === null) return { status: "missing", value: null };
|
|
34
|
+
if (!Number.isFinite(value)) return { status: "non-finite", value: null, nonFiniteKind: Number.isNaN(value) ? "nan" : value > 0 ? "positive-infinity" : "negative-infinity" };
|
|
35
|
+
return { status: "observed", value: Object.is(value, -0) ? 0 : value };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function equalAudit(left: DiagnosticExposureAuditObservation, right: DiagnosticExposureAuditObservation): boolean {
|
|
39
|
+
return left.sourceGroup === right.sourceGroup && left.origin === right.origin && left.complete === right.complete &&
|
|
40
|
+
JSON.stringify(left.value) === JSON.stringify(right.value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function reconcileDiagnosticExposures(
|
|
44
|
+
observations: readonly DiagnosticExposureObservation[],
|
|
45
|
+
timingByMeasure: Readonly<Record<string, "origin-static" | "valuation-specific">>,
|
|
46
|
+
): readonly ReconciledDiagnosticExposure[] {
|
|
47
|
+
const cohorts = new Map<string, DiagnosticExposureObservation[]>();
|
|
48
|
+
for (const observation of observations) {
|
|
49
|
+
const timing = timingByMeasure[observation.measureId];
|
|
50
|
+
const identity = timing === "valuation-specific"
|
|
51
|
+
? `${observation.measureId}\u0000${observation.key}\u0000${observation.valuation ?? ""}`
|
|
52
|
+
: `${observation.measureId}\u0000${observation.key}`;
|
|
53
|
+
const cohort = cohorts.get(identity) ?? [];
|
|
54
|
+
cohort.push(observation);
|
|
55
|
+
cohorts.set(identity, cohort);
|
|
56
|
+
}
|
|
57
|
+
return Object.freeze([...cohorts.values()].map((cohort): ReconciledDiagnosticExposure => {
|
|
58
|
+
const first = cohort[0]!;
|
|
59
|
+
const timing = timingByMeasure[first.measureId];
|
|
60
|
+
const audited = cohort.map((item): DiagnosticExposureAuditObservation => ({
|
|
61
|
+
sourceGroup: item.sourceGroup,
|
|
62
|
+
origin: item.origin,
|
|
63
|
+
...(item.valuation === undefined ? {} : { valuation: item.valuation }),
|
|
64
|
+
value: auditDiagnosticNumber(item.value),
|
|
65
|
+
complete: item.complete,
|
|
66
|
+
...(item.source === undefined ? {} : { source: item.source }),
|
|
67
|
+
}));
|
|
68
|
+
const issues: ("missing" | "incomplete" | "non-finite" | "duplicate" | "conflict")[] = [];
|
|
69
|
+
if (audited.some((item) => item.value.status === "missing")) issues.push("missing");
|
|
70
|
+
if (audited.some((item) => !item.complete)) issues.push("incomplete");
|
|
71
|
+
if (audited.some((item) => item.value.status === "non-finite")) issues.push("non-finite");
|
|
72
|
+
if (timing === "valuation-specific" && audited.length > 1) issues.push("duplicate");
|
|
73
|
+
if (audited.slice(1).some((item) => !equalAudit(audited[0]!, item))) issues.push("conflict");
|
|
74
|
+
const validStaticCopies = timing === "origin-static" && issues.length === 0;
|
|
75
|
+
if (issues.length > 0) return Object.freeze({ measureId: first.measureId, key: first.key, status: "invalid", issues: Object.freeze(issues), value: null, observations: Object.freeze(audited) });
|
|
76
|
+
const value = audited[0]!.value;
|
|
77
|
+
if (value.status !== "observed") throw new Error("unreachable invalid exposure state");
|
|
78
|
+
const sources = audited.flatMap((item) => item.source ? [item.source] : []).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
measureId: first.measureId,
|
|
81
|
+
key: first.key,
|
|
82
|
+
status: "valid",
|
|
83
|
+
sourceGroup: first.sourceGroup,
|
|
84
|
+
origin: first.origin,
|
|
85
|
+
...(timing === "valuation-specific" ? { valuation: first.valuation! } : {}),
|
|
86
|
+
value: value.value,
|
|
87
|
+
deduplicated: validStaticCopies ? audited.length - 1 : 0,
|
|
88
|
+
sources: Object.freeze(sources),
|
|
89
|
+
});
|
|
90
|
+
}).sort((left, right) => `${left.measureId}\u0000${left.key}` < `${right.measureId}\u0000${right.key}` ? -1 : 1));
|
|
91
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiagnosticValidationIssue,
|
|
3
|
+
DiagnosticValidationIssueCode,
|
|
4
|
+
} from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const MAX_DIAGNOSTIC_EXPRESSION_DEPTH = 64;
|
|
7
|
+
export const MAX_DIAGNOSTIC_EXPRESSION_NODES = 10_000;
|
|
8
|
+
export const MAX_DIAGNOSTIC_DEFINITION_EXPRESSION_NODES = 100_000;
|
|
9
|
+
|
|
10
|
+
export type DiagnosticMeasureExpression =
|
|
11
|
+
| { op: "measure"; measureId: string }
|
|
12
|
+
| { op: "add"; terms: readonly DiagnosticMeasureExpression[] }
|
|
13
|
+
| {
|
|
14
|
+
op: "subtract";
|
|
15
|
+
left: DiagnosticMeasureExpression;
|
|
16
|
+
right: DiagnosticMeasureExpression;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type DiagnosticRoleExpression =
|
|
20
|
+
| { op: "role"; role: string }
|
|
21
|
+
| { op: "add"; terms: readonly DiagnosticRoleExpression[] }
|
|
22
|
+
| {
|
|
23
|
+
op: "subtract";
|
|
24
|
+
left: DiagnosticRoleExpression;
|
|
25
|
+
right: DiagnosticRoleExpression;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type DiagnosticClaimExpression =
|
|
29
|
+
| { op: "measure"; measureId: string }
|
|
30
|
+
| { op: "add"; terms: readonly DiagnosticClaimExpression[] }
|
|
31
|
+
| {
|
|
32
|
+
op: "subtract";
|
|
33
|
+
left: DiagnosticClaimExpression;
|
|
34
|
+
right: DiagnosticClaimExpression;
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
op: "claim-layer";
|
|
38
|
+
measureId: string;
|
|
39
|
+
attachment: number;
|
|
40
|
+
/** Layer width; null means unlimited above attachment. */
|
|
41
|
+
limit: number | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type DiagnosticExpressionKind = "measure" | "role" | "claim";
|
|
45
|
+
|
|
46
|
+
export interface DiagnosticExpressionWalk {
|
|
47
|
+
readonly dependencies: readonly string[];
|
|
48
|
+
readonly nodeCount: number;
|
|
49
|
+
readonly maxDepth: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface WalkFrame {
|
|
53
|
+
readonly value: unknown;
|
|
54
|
+
readonly path: string;
|
|
55
|
+
readonly depth: number;
|
|
56
|
+
readonly ancestors: readonly object[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
60
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
61
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
62
|
+
return prototype === Object.prototype || prototype === null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function issue(
|
|
66
|
+
issues: DiagnosticValidationIssue[],
|
|
67
|
+
code: DiagnosticValidationIssueCode,
|
|
68
|
+
path: string,
|
|
69
|
+
message: string,
|
|
70
|
+
): void {
|
|
71
|
+
issues.push({ domain: "definition", code, path, message });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Iterative structural/resource walk used before recursive normalization. It
|
|
76
|
+
* deliberately accepts unknown so hostile inputs cannot overflow the host
|
|
77
|
+
* stack before the compiler can return a typed issue.
|
|
78
|
+
*/
|
|
79
|
+
export function walkDiagnosticExpression(
|
|
80
|
+
value: unknown,
|
|
81
|
+
kind: DiagnosticExpressionKind,
|
|
82
|
+
path: string,
|
|
83
|
+
issues: DiagnosticValidationIssue[],
|
|
84
|
+
): DiagnosticExpressionWalk {
|
|
85
|
+
const dependencies = new Set<string>();
|
|
86
|
+
const stack: WalkFrame[] = [{ value, path, depth: 1, ancestors: [] }];
|
|
87
|
+
let nodeCount = 0;
|
|
88
|
+
let maxDepth = 0;
|
|
89
|
+
while (stack.length > 0) {
|
|
90
|
+
const frame = stack.pop()!;
|
|
91
|
+
nodeCount++;
|
|
92
|
+
maxDepth = Math.max(maxDepth, frame.depth);
|
|
93
|
+
if (frame.depth > MAX_DIAGNOSTIC_EXPRESSION_DEPTH) {
|
|
94
|
+
issue(
|
|
95
|
+
issues,
|
|
96
|
+
"expression-limit",
|
|
97
|
+
frame.path,
|
|
98
|
+
`Expression depth exceeds ${MAX_DIAGNOSTIC_EXPRESSION_DEPTH}`,
|
|
99
|
+
);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (nodeCount > MAX_DIAGNOSTIC_EXPRESSION_NODES) {
|
|
103
|
+
issue(
|
|
104
|
+
issues,
|
|
105
|
+
"expression-limit",
|
|
106
|
+
path,
|
|
107
|
+
`Expression node count exceeds ${MAX_DIAGNOSTIC_EXPRESSION_NODES}`,
|
|
108
|
+
);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (!isRecord(frame.value)) {
|
|
112
|
+
issue(issues, "invalid-type", frame.path, "Expression node must be a plain object");
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (frame.ancestors.includes(frame.value)) {
|
|
116
|
+
issue(issues, "cycle", frame.path, "Expression contains a cycle");
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const op = frame.value.op;
|
|
120
|
+
const nextAncestors = [...frame.ancestors, frame.value];
|
|
121
|
+
if (kind === "role" && op === "role") {
|
|
122
|
+
if (typeof frame.value.role === "string") dependencies.add(frame.value.role);
|
|
123
|
+
else issue(issues, "invalid-type", `${frame.path}.role`, "Role reference must be a string");
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if ((kind === "measure" || kind === "claim") && op === "measure") {
|
|
127
|
+
if (typeof frame.value.measureId === "string") dependencies.add(frame.value.measureId);
|
|
128
|
+
else issue(issues, "invalid-type", `${frame.path}.measureId`, "Measure reference must be a string");
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (kind === "claim" && op === "claim-layer") {
|
|
132
|
+
if (typeof frame.value.measureId === "string") dependencies.add(frame.value.measureId);
|
|
133
|
+
else issue(issues, "invalid-type", `${frame.path}.measureId`, "Measure reference must be a string");
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (op === "add") {
|
|
137
|
+
if (!Array.isArray(frame.value.terms) || frame.value.terms.length === 0) {
|
|
138
|
+
issue(issues, "invalid-type", `${frame.path}.terms`, "Add expression requires at least one term");
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
for (let index = frame.value.terms.length - 1; index >= 0; index--) {
|
|
142
|
+
stack.push({
|
|
143
|
+
value: frame.value.terms[index],
|
|
144
|
+
path: `${frame.path}.terms[${index}]`,
|
|
145
|
+
depth: frame.depth + 1,
|
|
146
|
+
ancestors: nextAncestors,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (op === "subtract") {
|
|
152
|
+
stack.push({
|
|
153
|
+
value: frame.value.right,
|
|
154
|
+
path: `${frame.path}.right`,
|
|
155
|
+
depth: frame.depth + 1,
|
|
156
|
+
ancestors: nextAncestors,
|
|
157
|
+
});
|
|
158
|
+
stack.push({
|
|
159
|
+
value: frame.value.left,
|
|
160
|
+
path: `${frame.path}.left`,
|
|
161
|
+
depth: frame.depth + 1,
|
|
162
|
+
ancestors: nextAncestors,
|
|
163
|
+
});
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
issue(issues, "invalid-type", `${frame.path}.op`, `Unknown ${kind} expression operator`);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
dependencies: [...dependencies].sort(),
|
|
170
|
+
nodeCount,
|
|
171
|
+
maxDepth,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { safeRatio } from "./util.js";
|
|
2
|
+
import type {
|
|
3
|
+
DiagnosticDeepReadonly,
|
|
4
|
+
DiagnosticFormulaTemplate,
|
|
5
|
+
DiagnosticMeasureKind,
|
|
6
|
+
DiagnosticMeasureStats,
|
|
7
|
+
DiagnosticMetricPresentation,
|
|
8
|
+
} from "./diagnosticDefinitions.js";
|
|
9
|
+
import type { DiagnosticMeasureExpression, DiagnosticRoleExpression } from "./diagnosticExpressions.js";
|
|
10
|
+
import type { DiagnosticExpressionOverflow, DiagnosticRuleNotEvaluatedReason } from "./diagnosticRules.js";
|
|
11
|
+
|
|
12
|
+
export const CASUALTY_FORMULA_TEMPLATES = Object.freeze([
|
|
13
|
+
{ id: "frequency", version: "1.0.0", roles: { claims: { kind: "count" }, exposure: { kind: "exposure" } }, numerator: { op: "role", role: "claims" }, denominator: { op: "role", role: "exposure" }, denominatorPolicy: "positive-or-null" },
|
|
14
|
+
{ id: "share", version: "1.0.0", roles: { part: { kind: "count", compatibilityGroup: "count-population" }, whole: { kind: "count", compatibilityGroup: "count-population" } }, numerator: { op: "role", role: "part" }, denominator: { op: "role", role: "whole" }, denominatorPolicy: "positive-or-null" },
|
|
15
|
+
{ id: "paid-to-incurred", version: "1.0.0", roles: { paid: { kind: "amount", compatibilityGroup: "amount-basis", developmentSemantics: "cumulative" }, incurred: { kind: "amount", compatibilityGroup: "amount-basis", developmentSemantics: "cumulative" } }, numerator: { op: "role", role: "paid" }, denominator: { op: "role", role: "incurred" }, denominatorPolicy: "positive-or-null" },
|
|
16
|
+
{ id: "amount-per-exposure", version: "1.0.0", roles: { amount: { kind: "amount" }, exposure: { kind: "exposure" } }, numerator: { op: "role", role: "amount" }, denominator: { op: "role", role: "exposure" }, denominatorPolicy: "positive-or-null" },
|
|
17
|
+
{ id: "amount-per-claim", version: "1.0.0", roles: { amount: { kind: "amount" }, claims: { kind: "count" } }, numerator: { op: "role", role: "amount" }, denominator: { op: "role", role: "claims" }, denominatorPolicy: "positive-or-null" },
|
|
18
|
+
{ id: "case-per-open", version: "1.0.0", roles: { incurred: { kind: "amount", compatibilityGroup: "amount-basis", developmentSemantics: "cumulative" }, paid: { kind: "amount", compatibilityGroup: "amount-basis", developmentSemantics: "cumulative" }, open: { kind: "count", developmentSemantics: "point-in-time" } }, numerator: { op: "subtract", left: { op: "role", role: "incurred" }, right: { op: "role", role: "paid" } }, denominator: { op: "role", role: "open" }, denominatorPolicy: "positive-or-null" },
|
|
19
|
+
] as const satisfies readonly DiagnosticFormulaTemplate[]);
|
|
20
|
+
|
|
21
|
+
export interface DiagnosticQuantitySemantics {
|
|
22
|
+
readonly kind: DiagnosticMeasureKind;
|
|
23
|
+
readonly unit: string;
|
|
24
|
+
readonly basisId?: string;
|
|
25
|
+
readonly countPopulationId?: string;
|
|
26
|
+
readonly exposureBasisId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DiagnosticQuantity extends DiagnosticQuantitySemantics {
|
|
30
|
+
readonly value: number | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DiagnosticMetricFinding {
|
|
34
|
+
readonly code: string;
|
|
35
|
+
readonly message: string;
|
|
36
|
+
readonly severity: "info" | "warning" | "fail";
|
|
37
|
+
readonly category: "structural" | "aggregation" | "calculation" | "rule" | "presentation";
|
|
38
|
+
readonly ruleId?: string;
|
|
39
|
+
readonly measureId?: string;
|
|
40
|
+
readonly instanceId?: string;
|
|
41
|
+
readonly expressionPath?: string;
|
|
42
|
+
readonly sourceGroup?: string;
|
|
43
|
+
readonly group?: string;
|
|
44
|
+
readonly origin?: string;
|
|
45
|
+
readonly valuation?: string;
|
|
46
|
+
readonly developmentAge?: number;
|
|
47
|
+
readonly ageUnit?: string;
|
|
48
|
+
readonly recordId?: string;
|
|
49
|
+
readonly claimId?: string;
|
|
50
|
+
readonly exposureKey?: string;
|
|
51
|
+
readonly sources: readonly import("./diagnosticDefinitions.js").DiagnosticSourceLocation[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FinalizedDiagnosticMeasure {
|
|
55
|
+
readonly quantity: DiagnosticQuantity;
|
|
56
|
+
readonly stats: DiagnosticMeasureStats;
|
|
57
|
+
readonly readiness: readonly DiagnosticRuleNotEvaluatedReason[];
|
|
58
|
+
readonly expressionOverflows?: readonly DiagnosticExpressionOverflow[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ExpressionResult {
|
|
62
|
+
readonly value: number | null;
|
|
63
|
+
readonly reasons: readonly DiagnosticRuleNotEvaluatedReason[];
|
|
64
|
+
readonly overflows: readonly DiagnosticExpressionOverflow[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function neumaier(values: readonly number[]): number | null {
|
|
68
|
+
let sum = 0;
|
|
69
|
+
let correction = 0;
|
|
70
|
+
for (const value of values) {
|
|
71
|
+
const next = sum + value;
|
|
72
|
+
correction += Math.abs(sum) >= Math.abs(value) ? (sum - next) + value : (value - next) + sum;
|
|
73
|
+
sum = next;
|
|
74
|
+
if (!Number.isFinite(sum) || !Number.isFinite(correction)) return null;
|
|
75
|
+
}
|
|
76
|
+
const result = sum + correction;
|
|
77
|
+
return Number.isFinite(result) ? (Object.is(result, -0) ? 0 : result) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function uniqueReasons(values: readonly DiagnosticRuleNotEvaluatedReason[]): readonly DiagnosticRuleNotEvaluatedReason[] {
|
|
81
|
+
const order: readonly DiagnosticRuleNotEvaluatedReason[] = ["missing", "imputed", "non-finite", "structural-ambiguity", "aggregation-overflow", "expression-overflow", "tolerance-overflow"];
|
|
82
|
+
return order.filter((reason) => values.includes(reason));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function evaluateDiagnosticMeasureExpression(
|
|
86
|
+
expression: DiagnosticDeepReadonly<DiagnosticMeasureExpression>,
|
|
87
|
+
measures: Readonly<Record<string, FinalizedDiagnosticMeasure>>,
|
|
88
|
+
path: string,
|
|
89
|
+
): ExpressionResult {
|
|
90
|
+
if (expression.op === "measure") {
|
|
91
|
+
const value = measures[expression.measureId];
|
|
92
|
+
return value
|
|
93
|
+
? { value: value.quantity.value, reasons: value.readiness, overflows: value.expressionOverflows ?? [] }
|
|
94
|
+
: { value: null, reasons: ["missing"], overflows: [] };
|
|
95
|
+
}
|
|
96
|
+
const children = expression.op === "add"
|
|
97
|
+
? expression.terms.map((term, index) => evaluateDiagnosticMeasureExpression(term, measures, `${path}.terms[${index}]`))
|
|
98
|
+
: [
|
|
99
|
+
evaluateDiagnosticMeasureExpression(expression.left, measures, `${path}.left`),
|
|
100
|
+
evaluateDiagnosticMeasureExpression(expression.right, measures, `${path}.right`),
|
|
101
|
+
];
|
|
102
|
+
const reasons = uniqueReasons(children.flatMap((child) => child.reasons));
|
|
103
|
+
const overflows = children.flatMap((child) => child.overflows);
|
|
104
|
+
if (children.some((child) => child.value === null)) return { value: null, reasons, overflows };
|
|
105
|
+
const value = expression.op === "add"
|
|
106
|
+
? neumaier(children.map((child) => child.value!))
|
|
107
|
+
: children[0]!.value! - children[1]!.value!;
|
|
108
|
+
if (value === null || !Number.isFinite(value)) {
|
|
109
|
+
return {
|
|
110
|
+
value: null,
|
|
111
|
+
reasons: uniqueReasons([...reasons, "expression-overflow"]),
|
|
112
|
+
overflows: [...overflows, { expressionPath: path, sources: [] }],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { value: Object.is(value, -0) ? 0 : value, reasons, overflows };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function evaluateDiagnosticRoleExpression(
|
|
119
|
+
expression: DiagnosticDeepReadonly<DiagnosticRoleExpression>,
|
|
120
|
+
bindings: Readonly<Record<string, ExpressionResult>>,
|
|
121
|
+
path: string,
|
|
122
|
+
): ExpressionResult {
|
|
123
|
+
if (expression.op === "role") return bindings[expression.role] ?? { value: null, reasons: ["missing"], overflows: [] };
|
|
124
|
+
const children = expression.op === "add"
|
|
125
|
+
? expression.terms.map((term, index) => evaluateDiagnosticRoleExpression(term, bindings, `${path}.terms[${index}]`))
|
|
126
|
+
: [evaluateDiagnosticRoleExpression(expression.left, bindings, `${path}.left`), evaluateDiagnosticRoleExpression(expression.right, bindings, `${path}.right`)];
|
|
127
|
+
const reasons = uniqueReasons(children.flatMap((child) => child.reasons));
|
|
128
|
+
const overflows = children.flatMap((child) => child.overflows);
|
|
129
|
+
if (children.some((child) => child.value === null)) return { value: null, reasons, overflows };
|
|
130
|
+
const value = expression.op === "add" ? neumaier(children.map((child) => child.value!)) : children[0]!.value! - children[1]!.value!;
|
|
131
|
+
if (value === null || !Number.isFinite(value)) return { value: null, reasons: uniqueReasons([...reasons, "expression-overflow"]), overflows: [...overflows, { expressionPath: path, sources: [] }] };
|
|
132
|
+
return { value: Object.is(value, -0) ? 0 : value, reasons, overflows };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function diagnosticRawRatio(numerator: number | null, denominator: number | null): number | null {
|
|
136
|
+
if (numerator === null || denominator === null || !Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) return null;
|
|
137
|
+
const ratio = safeRatio(numerator, denominator);
|
|
138
|
+
return ratio !== null && Number.isFinite(ratio) ? ratio : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function applyDiagnosticPresentation(
|
|
142
|
+
rawValue: number | null,
|
|
143
|
+
presentation: DiagnosticDeepReadonly<DiagnosticMetricPresentation>,
|
|
144
|
+
): { readonly value: number | null; readonly finding: DiagnosticMetricFinding | null } {
|
|
145
|
+
if (rawValue === null) return { value: null, finding: null };
|
|
146
|
+
const value = rawValue * presentation.scale;
|
|
147
|
+
return Number.isFinite(value)
|
|
148
|
+
? { value: Object.is(value, -0) ? 0 : value, finding: null }
|
|
149
|
+
: { value: null, finding: { code: "diagnostic-presentation-overflow", message: "Diagnostic presentation scaling overflowed", severity: "fail", category: "presentation", sources: [] } };
|
|
150
|
+
}
|