@actuarial-ts/core 0.7.0 → 0.8.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.
Files changed (115) hide show
  1. package/README.md +46 -2
  2. package/dist/analysisRecipe.d.ts +55 -0
  3. package/dist/analysisRecipe.d.ts.map +1 -0
  4. package/dist/analysisRecipe.js +627 -0
  5. package/dist/analysisRecipe.js.map +1 -0
  6. package/dist/classifications.d.ts +30 -0
  7. package/dist/classifications.d.ts.map +1 -0
  8. package/dist/classifications.js +207 -0
  9. package/dist/classifications.js.map +1 -0
  10. package/dist/customizationContracts.d.ts +749 -0
  11. package/dist/customizationContracts.d.ts.map +1 -0
  12. package/dist/customizationContracts.js +19 -0
  13. package/dist/customizationContracts.js.map +1 -0
  14. package/dist/descriptiveStatistics.d.ts +8 -0
  15. package/dist/descriptiveStatistics.d.ts.map +1 -0
  16. package/dist/descriptiveStatistics.js +283 -0
  17. package/dist/descriptiveStatistics.js.map +1 -0
  18. package/dist/diagnosticAggregation.d.ts.map +1 -1
  19. package/dist/diagnosticAggregation.js +16 -0
  20. package/dist/diagnosticAggregation.js.map +1 -1
  21. package/dist/diagnosticFreeze.d.ts +4 -0
  22. package/dist/diagnosticFreeze.d.ts.map +1 -0
  23. package/dist/diagnosticFreeze.js +37 -0
  24. package/dist/diagnosticFreeze.js.map +1 -0
  25. package/dist/diagnosticIdentityStream.d.ts +4 -0
  26. package/dist/diagnosticIdentityStream.d.ts.map +1 -1
  27. package/dist/diagnosticIdentityStream.js +32 -2
  28. package/dist/diagnosticIdentityStream.js.map +1 -1
  29. package/dist/diagnosticPreparation.d.ts +21 -0
  30. package/dist/diagnosticPreparation.d.ts.map +1 -1
  31. package/dist/diagnosticPreparation.js +231 -19
  32. package/dist/diagnosticPreparation.js.map +1 -1
  33. package/dist/diagnosticReview.d.ts.map +1 -1
  34. package/dist/diagnosticReview.js +43 -25
  35. package/dist/diagnosticReview.js.map +1 -1
  36. package/dist/diagnosticReviewSources.d.ts.map +1 -1
  37. package/dist/diagnosticReviewSources.js +25 -4
  38. package/dist/diagnosticReviewSources.js.map +1 -1
  39. package/dist/diagnosticReviewStore.d.ts +2 -0
  40. package/dist/diagnosticReviewStore.d.ts.map +1 -1
  41. package/dist/diagnosticReviewStore.js +110 -36
  42. package/dist/diagnosticReviewStore.js.map +1 -1
  43. package/dist/diagnosticRunner.d.ts.map +1 -1
  44. package/dist/diagnosticRunner.js +1 -8
  45. package/dist/diagnosticRunner.js.map +1 -1
  46. package/dist/exposureEarning.d.ts +12 -0
  47. package/dist/exposureEarning.d.ts.map +1 -0
  48. package/dist/exposureEarning.js +276 -0
  49. package/dist/exposureEarning.js.map +1 -0
  50. package/dist/exposureMovements.d.ts +8 -0
  51. package/dist/exposureMovements.d.ts.map +1 -0
  52. package/dist/exposureMovements.js +70 -0
  53. package/dist/exposureMovements.js.map +1 -0
  54. package/dist/exposureRevisions.d.ts +13 -0
  55. package/dist/exposureRevisions.d.ts.map +1 -0
  56. package/dist/exposureRevisions.js +161 -0
  57. package/dist/exposureRevisions.js.map +1 -0
  58. package/dist/financialPipeline.d.ts +14 -0
  59. package/dist/financialPipeline.d.ts.map +1 -0
  60. package/dist/financialPipeline.js +75 -0
  61. package/dist/financialPipeline.js.map +1 -0
  62. package/dist/financialTerms.d.ts +20 -0
  63. package/dist/financialTerms.d.ts.map +1 -0
  64. package/dist/financialTerms.js +205 -0
  65. package/dist/financialTerms.js.map +1 -0
  66. package/dist/fnvAccumulator.d.ts.map +1 -1
  67. package/dist/fnvAccumulator.js +11 -5
  68. package/dist/fnvAccumulator.js.map +1 -1
  69. package/dist/historyPreparation.d.ts +8 -0
  70. package/dist/historyPreparation.d.ts.map +1 -0
  71. package/dist/historyPreparation.js +557 -0
  72. package/dist/historyPreparation.js.map +1 -0
  73. package/dist/index.d.ts +15 -2
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +15 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/policyPeriods.d.ts +29 -0
  78. package/dist/policyPeriods.d.ts.map +1 -0
  79. package/dist/policyPeriods.js +322 -0
  80. package/dist/policyPeriods.js.map +1 -0
  81. package/dist/populations.d.ts +11 -0
  82. package/dist/populations.d.ts.map +1 -0
  83. package/dist/populations.js +264 -0
  84. package/dist/populations.js.map +1 -0
  85. package/dist/reservingAdapter.d.ts +16 -0
  86. package/dist/reservingAdapter.d.ts.map +1 -0
  87. package/dist/reservingAdapter.js +74 -0
  88. package/dist/reservingAdapter.js.map +1 -0
  89. package/dist/version.d.ts +1 -1
  90. package/dist/version.js +1 -1
  91. package/package.json +2 -2
  92. package/src/analysisRecipe.ts +696 -0
  93. package/src/classifications.ts +231 -0
  94. package/src/customizationContracts.ts +763 -0
  95. package/src/descriptiveStatistics.ts +320 -0
  96. package/src/diagnosticAggregation.ts +16 -0
  97. package/src/diagnosticFreeze.ts +37 -0
  98. package/src/diagnosticIdentityStream.ts +37 -2
  99. package/src/diagnosticPreparation.ts +259 -26
  100. package/src/diagnosticReview.ts +49 -27
  101. package/src/diagnosticReviewSources.ts +22 -3
  102. package/src/diagnosticReviewStore.ts +139 -54
  103. package/src/diagnosticRunner.ts +1 -11
  104. package/src/exposureEarning.ts +314 -0
  105. package/src/exposureMovements.ts +78 -0
  106. package/src/exposureRevisions.ts +186 -0
  107. package/src/financialPipeline.ts +93 -0
  108. package/src/financialTerms.ts +263 -0
  109. package/src/fnvAccumulator.ts +11 -5
  110. package/src/historyPreparation.ts +645 -0
  111. package/src/index.ts +28 -1
  112. package/src/policyPeriods.ts +375 -0
  113. package/src/populations.ts +307 -0
  114. package/src/reservingAdapter.ts +98 -0
  115. package/src/version.ts +1 -1
@@ -0,0 +1,375 @@
1
+ import type {
2
+ AnalysisComparisonCoordinate,
3
+ AnalysisOriginAssignment,
4
+ MatchedAccidentCohort,
5
+ AnalysisPeriodDefinition,
6
+ HistoricalScalar,
7
+ PolicyCoveragePeriod,
8
+ PolicyPeriodAssignment,
9
+ PreparedHistoricalObservation,
10
+ } from "./customizationContracts.js";
11
+ import { isRealIsoDate, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
12
+ import { DiagnosticValidationError, type DiagnosticValidationIssue } from "./types.js";
13
+
14
+ function compareText(left: string, right: string): number {
15
+ return left === right ? 0 : left < right ? -1 : 1;
16
+ }
17
+
18
+ function scheduleIssues(schedule: readonly PolicyCoveragePeriod[]): DiagnosticValidationIssue[] {
19
+ const issues: DiagnosticValidationIssue[] = [];
20
+ const ids = new Set<string>();
21
+ schedule.forEach((period, index) => {
22
+ if (ids.has(period.id))
23
+ issues.push({
24
+ domain: "configuration",
25
+ code: "duplicate-id",
26
+ path: `$.policySchedule[${index}].id`,
27
+ message: "Policy period ID is duplicated",
28
+ });
29
+ ids.add(period.id);
30
+ if (!isRealIsoDate(period.startDate) || !isRealIsoDate(period.endDate) || period.startDate >= period.endDate)
31
+ issues.push({
32
+ domain: "configuration",
33
+ code: "invalid-period",
34
+ path: `$.policySchedule[${index}]`,
35
+ message: "Policy period requires real dates with startDate before endDate",
36
+ });
37
+ });
38
+ return issues;
39
+ }
40
+
41
+ export function validatePolicyCoverageSchedule(
42
+ schedule: readonly PolicyCoveragePeriod[],
43
+ ): readonly PolicyCoveragePeriod[] {
44
+ const issues = scheduleIssues(schedule);
45
+ if (issues.length > 0) throw new DiagnosticValidationError(issues);
46
+ return snapshotDiagnosticJson(
47
+ [...schedule].sort(
48
+ (left, right) =>
49
+ compareText(left.startDate, right.startDate) ||
50
+ compareText(left.endDate, right.endDate) ||
51
+ compareText(left.id, right.id),
52
+ ),
53
+ );
54
+ }
55
+
56
+ function scalarMatches(actual: HistoricalScalar | readonly HistoricalScalar[] | undefined, expected: HistoricalScalar): boolean {
57
+ return Array.isArray(actual) ? actual.some((value) => Object.is(value, expected)) : Object.is(actual, expected);
58
+ }
59
+
60
+ function scopeMatches(
61
+ observation: PreparedHistoricalObservation,
62
+ period: PolicyCoveragePeriod,
63
+ ): boolean {
64
+ return Object.entries(period.scope).every(([id, expected]) =>
65
+ scalarMatches(observation.dimensions[id], expected),
66
+ );
67
+ }
68
+
69
+ export interface AssignPolicyPeriodInput {
70
+ readonly observation: PreparedHistoricalObservation;
71
+ readonly schedule: readonly PolicyCoveragePeriod[];
72
+ readonly relationship: "policy" | "coverage" | "date-only";
73
+ readonly dateBasis?: "accident" | "report";
74
+ }
75
+
76
+ export function assignPolicyPeriod(input: AssignPolicyPeriodInput): PolicyPeriodAssignment {
77
+ const schedule = validatePolicyCoverageSchedule(input.schedule);
78
+ let candidates: PolicyCoveragePeriod[];
79
+ if (input.relationship === "policy") {
80
+ const ids = new Set(input.observation.relationships.policyIds ?? []);
81
+ candidates = schedule.filter((period) => ids.has(period.id));
82
+ } else if (input.relationship === "coverage") {
83
+ const ids = new Set(input.observation.relationships.coverageIds ?? []);
84
+ candidates = schedule.filter((period) => ids.has(period.id));
85
+ } else {
86
+ const date =
87
+ input.dateBasis === "report"
88
+ ? input.observation.reportDate
89
+ : input.observation.accidentDate;
90
+ if (date === null)
91
+ return snapshotDiagnosticJson({
92
+ status: "unavailable",
93
+ period: null,
94
+ candidates: [],
95
+ finding: {
96
+ code: "missing-prerequisite",
97
+ capability: "irregular-periods",
98
+ path: `$.observations.${input.observation.sourceNamespace}.${input.observation.claimId}`,
99
+ message: `Date-only policy assignment requires ${input.dateBasis === "report" ? "reportDate" : "accidentDate"}`,
100
+ requiredFields: [input.dateBasis === "report" ? "reportDate" : "accidentDate"],
101
+ },
102
+ });
103
+ candidates = schedule.filter(
104
+ (period) => period.startDate <= date && date < period.endDate && scopeMatches(input.observation, period),
105
+ );
106
+ }
107
+ const candidateIds = candidates.map((period) => period.id).sort(compareText);
108
+ if (candidates.length === 1)
109
+ return snapshotDiagnosticJson({
110
+ status: "assigned",
111
+ period: candidates[0]!,
112
+ candidates: candidateIds,
113
+ finding: null,
114
+ });
115
+ if (candidates.length > 1)
116
+ return snapshotDiagnosticJson({
117
+ status: "ambiguous",
118
+ period: null,
119
+ candidates: candidateIds,
120
+ finding: {
121
+ code: "ambiguous-attribution",
122
+ capability: "irregular-periods",
123
+ path: `$.observations.${input.observation.sourceNamespace}.${input.observation.claimId}`,
124
+ message: `Policy assignment matches multiple periods: ${candidateIds.join(", ")}`,
125
+ },
126
+ });
127
+ return snapshotDiagnosticJson({
128
+ status: "unavailable",
129
+ period: null,
130
+ candidates: [],
131
+ finding: {
132
+ code: "missing-prerequisite",
133
+ capability: "irregular-periods",
134
+ path: `$.observations.${input.observation.sourceNamespace}.${input.observation.claimId}`,
135
+ message: "No policy period matches the declared relationship, date, and scope",
136
+ },
137
+ });
138
+ }
139
+
140
+ function calendarLabel(date: string, cadence: "month" | "quarter" | "year"): string {
141
+ const [year, month] = date.split("-").map(Number) as [number, number, number];
142
+ if (cadence === "year") return String(year).padStart(4, "0");
143
+ if (cadence === "quarter") return `${String(year).padStart(4, "0")}Q${Math.floor((month - 1) / 3) + 1}`;
144
+ return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}`;
145
+ }
146
+
147
+ function fiscalLabel(
148
+ date: string,
149
+ cadence: "quarter" | "year",
150
+ startMonth: number,
151
+ ): string {
152
+ const [calendarYear, calendarMonth] = date.split("-").map(Number) as [number, number, number];
153
+ const offset = (calendarMonth - startMonth + 12) % 12;
154
+ const fiscalStartYear = calendarMonth >= startMonth ? calendarYear : calendarYear - 1;
155
+ const fiscalYear = startMonth === 1 ? fiscalStartYear : fiscalStartYear + 1;
156
+ return cadence === "year"
157
+ ? `FY${fiscalYear}`
158
+ : `FY${fiscalYear}Q${Math.floor(offset / 3) + 1}`;
159
+ }
160
+
161
+ export function assignAnalysisOrigin(
162
+ observation: PreparedHistoricalObservation,
163
+ period: AnalysisPeriodDefinition,
164
+ schedule: readonly PolicyCoveragePeriod[] = [],
165
+ ): AnalysisOriginAssignment {
166
+ if (period.originBasis === "policy") {
167
+ const assignment = assignPolicyPeriod({
168
+ observation,
169
+ schedule,
170
+ relationship:
171
+ (observation.relationships.coverageIds?.length ?? 0) > 0
172
+ ? "coverage"
173
+ : (observation.relationships.policyIds?.length ?? 0) > 0
174
+ ? "policy"
175
+ : "date-only",
176
+ dateBasis: "accident",
177
+ });
178
+ return snapshotDiagnosticJson({
179
+ status: assignment.status,
180
+ originId: assignment.period?.id ?? null,
181
+ label: assignment.period?.label ?? null,
182
+ period: assignment.period,
183
+ finding: assignment.finding,
184
+ });
185
+ }
186
+ const date = period.originBasis === "accident" ? observation.accidentDate : observation.reportDate;
187
+ if (date === null)
188
+ return snapshotDiagnosticJson({
189
+ status: "unavailable",
190
+ originId: null,
191
+ label: null,
192
+ period: null,
193
+ finding: {
194
+ code: "missing-prerequisite",
195
+ capability: "irregular-periods",
196
+ path: `$.observations.${observation.sourceNamespace}.${observation.claimId}`,
197
+ message: `${period.originBasis} origin requires its source date`,
198
+ requiredFields: [period.originBasis === "accident" ? "accidentDate" : "reportDate"],
199
+ },
200
+ });
201
+ if (period.grouping.kind === "policy-schedule")
202
+ return snapshotDiagnosticJson({
203
+ status: "unavailable",
204
+ originId: null,
205
+ label: null,
206
+ period: null,
207
+ finding: {
208
+ code: "unsupported-operation",
209
+ capability: "irregular-periods",
210
+ path: "$.period.grouping",
211
+ message: "Accident/report origins require calendar or fiscal grouping",
212
+ },
213
+ });
214
+ const label =
215
+ period.grouping.kind === "calendar"
216
+ ? calendarLabel(date, period.grouping.cadence)
217
+ : fiscalLabel(date, period.grouping.cadence, period.grouping.startMonth);
218
+ return snapshotDiagnosticJson({
219
+ status: "assigned",
220
+ originId: label,
221
+ label,
222
+ period: null,
223
+ finding: null,
224
+ });
225
+ }
226
+
227
+ function daysFromCivil(date: string): number {
228
+ let [year, month, day] = date.split("-").map(Number) as [number, number, number];
229
+ year -= month <= 2 ? 1 : 0;
230
+ const era = Math.floor(year / 400);
231
+ const yearOfEra = year - era * 400;
232
+ const shiftedMonth = month + (month > 2 ? -3 : 9);
233
+ const dayOfYear = Math.floor((153 * shiftedMonth + 2) / 5) + day - 1;
234
+ const dayOfEra = yearOfEra * 365 + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100) + dayOfYear;
235
+ return era * 146097 + dayOfEra;
236
+ }
237
+
238
+ export function daysBetweenIsoDates(startDate: string, endDate: string): number {
239
+ if (!isRealIsoDate(startDate) || !isRealIsoDate(endDate))
240
+ throw new DiagnosticValidationError([
241
+ {
242
+ domain: "configuration",
243
+ code: "invalid-period",
244
+ path: "$.date",
245
+ message: "Day difference requires real Gregorian dates",
246
+ },
247
+ ]);
248
+ return daysFromCivil(endDate) - daysFromCivil(startDate);
249
+ }
250
+
251
+ function leapYear(year: number): boolean {
252
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
253
+ }
254
+
255
+ /** Converts a source-system inclusive expiration to the internal end-exclusive boundary. */
256
+ export function inclusiveExpirationToEndExclusive(expirationDate: string): string {
257
+ if (!isRealIsoDate(expirationDate))
258
+ throw new DiagnosticValidationError([{ domain: "input", code: "invalid-period", path: "$.expirationDate", message: "Inclusive expiration must be a real Gregorian date" }]);
259
+ let [year, month, day] = expirationDate.split("-").map(Number) as [number, number, number];
260
+ const days = [31, leapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
261
+ day += 1;
262
+ if (day > days[month - 1]!) { day = 1; month += 1; }
263
+ if (month > 12) { month = 1; year += 1; }
264
+ if (year > 9999)
265
+ throw new DiagnosticValidationError([{ domain: "input", code: "invalid-period", path: "$.expirationDate", message: "Inclusive expiration cannot be represented as an end-exclusive date" }]);
266
+ return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
267
+ }
268
+
269
+ /**
270
+ * Selects the same relative accident-day window inside each irregular policy
271
+ * period. Each claim stays namespaced and appears at most once per period.
272
+ */
273
+ export function matchAccidentCohorts(input: {
274
+ readonly observations: readonly PreparedHistoricalObservation[];
275
+ readonly periods: readonly PolicyCoveragePeriod[];
276
+ readonly startOffsetDays: number;
277
+ readonly endOffsetDays: number;
278
+ }): readonly MatchedAccidentCohort[] {
279
+ if (!Number.isSafeInteger(input.startOffsetDays) || !Number.isSafeInteger(input.endOffsetDays) || input.startOffsetDays < 0 || input.endOffsetDays <= input.startOffsetDays)
280
+ throw new DiagnosticValidationError([{ domain: "configuration", code: "invalid-period", path: "$.cohortOffsets", message: "Matched cohort offsets require nonnegative safe integers with end after start" }]);
281
+ const periods = validatePolicyCoverageSchedule(input.periods);
282
+ return snapshotDiagnosticJson(periods.map((period) => {
283
+ const keys = new Set<string>();
284
+ for (const observation of input.observations) {
285
+ if (observation.accidentDate === null || !scopeMatches(observation, period)) continue;
286
+ const offset = daysBetweenIsoDates(period.startDate, observation.accidentDate);
287
+ if (offset >= input.startOffsetDays && offset < input.endOffsetDays)
288
+ keys.add(`${observation.sourceNamespace}:${observation.claimId}`);
289
+ }
290
+ return {
291
+ periodId: period.id,
292
+ startOffsetDays: input.startOffsetDays,
293
+ endOffsetDays: input.endOffsetDays,
294
+ observationKeys: [...keys].sort(compareText),
295
+ };
296
+ }));
297
+ }
298
+
299
+ function completeCalendarMonths(start: string, end: string): number {
300
+ const [startYear, startMonth, startDay] = start.split("-").map(Number) as [number, number, number];
301
+ const [endYear, endMonth, endDay] = end.split("-").map(Number) as [number, number, number];
302
+ let months = (endYear - startYear) * 12 + endMonth - startMonth;
303
+ if (months > 0 && endDay < startDay) months -= 1;
304
+ if (months < 0 && endDay > startDay) months += 1;
305
+ return months;
306
+ }
307
+
308
+ export function analysisComparisonCoordinate(input: {
309
+ readonly period: AnalysisPeriodDefinition;
310
+ readonly evaluationDate: string;
311
+ readonly policyPeriod?: PolicyCoveragePeriod | null;
312
+ }): AnalysisComparisonCoordinate {
313
+ if (!isRealIsoDate(input.evaluationDate))
314
+ throw new DiagnosticValidationError([
315
+ {
316
+ domain: "view",
317
+ code: "invalid-period",
318
+ path: "$.evaluationDate",
319
+ message: "evaluationDate must be a real Gregorian date",
320
+ },
321
+ ]);
322
+ const clock = input.period.comparisonClock;
323
+ if (clock === "common-evaluation-date")
324
+ return snapshotDiagnosticJson({
325
+ status: "available",
326
+ clock,
327
+ evaluationDate: input.evaluationDate,
328
+ age: null,
329
+ ageUnit: null,
330
+ finding: null,
331
+ });
332
+ if (clock === "matched-accident-cohorts")
333
+ return snapshotDiagnosticJson({
334
+ status: "unavailable",
335
+ clock,
336
+ evaluationDate: input.evaluationDate,
337
+ age: null,
338
+ ageUnit: null,
339
+ finding: {
340
+ code: "missing-prerequisite",
341
+ capability: "irregular-periods",
342
+ path: "$.period.comparisonClock",
343
+ message: "Matched accident cohorts require an explicit constituent-cohort definition",
344
+ requiredFields: ["matchedCohortDefinition"],
345
+ },
346
+ });
347
+ if (input.policyPeriod === undefined || input.policyPeriod === null)
348
+ return snapshotDiagnosticJson({
349
+ status: "unavailable",
350
+ clock,
351
+ evaluationDate: input.evaluationDate,
352
+ age: null,
353
+ ageUnit: null,
354
+ finding: {
355
+ code: "missing-prerequisite",
356
+ capability: "irregular-periods",
357
+ path: "$.policyPeriod",
358
+ message: `${clock} requires an assigned policy period`,
359
+ requiredFields: ["policyPeriod"],
360
+ },
361
+ });
362
+ const start = clock === "since-period-start" ? input.policyPeriod.startDate : input.policyPeriod.endDate;
363
+ const age =
364
+ input.period.ageConvention === "exact-days"
365
+ ? daysFromCivil(input.evaluationDate) - daysFromCivil(start)
366
+ : completeCalendarMonths(start, input.evaluationDate);
367
+ return snapshotDiagnosticJson({
368
+ status: "available",
369
+ clock,
370
+ evaluationDate: input.evaluationDate,
371
+ age,
372
+ ageUnit: input.period.ageConvention === "exact-days" ? "day" : "month",
373
+ finding: null,
374
+ });
375
+ }
@@ -0,0 +1,307 @@
1
+ import type {
2
+ AnalysisDimensionDefinition,
3
+ AnalysisPopulationMembership,
4
+ AnalysisPredicate,
5
+ AnalysisPredicateOutcome,
6
+ AnalysisScopeDefinition,
7
+ HistoricalScalar,
8
+ PreparedHistoricalObservation,
9
+ } from "./customizationContracts.js";
10
+ import { isRealIsoDate, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
11
+ import { DiagnosticValidationError, type DiagnosticValidationIssue } from "./types.js";
12
+
13
+ const MAX_PREDICATE_DEPTH = 64;
14
+ const MAX_PREDICATE_NODES = 1_000;
15
+
16
+ function scalarType(value: HistoricalScalar): "string" | "number" | "boolean" | "null" {
17
+ if (value === null) return "null";
18
+ if (typeof value === "string") return "string";
19
+ if (typeof value === "number") return "number";
20
+ return "boolean";
21
+ }
22
+
23
+ function compatible(
24
+ value: HistoricalScalar,
25
+ dimension: AnalysisDimensionDefinition,
26
+ ): boolean {
27
+ if (value === null) return true;
28
+ if (dimension.type === "date") return typeof value === "string" && isRealIsoDate(value);
29
+ if (dimension.type === "entity-reference") return typeof value === "string";
30
+ return typeof value === dimension.type && (typeof value !== "number" || Number.isFinite(value));
31
+ }
32
+
33
+ /** Validates the closed predicate vocabulary, references, types, and bounds. */
34
+ export function validateAnalysisPredicate(
35
+ predicate: AnalysisPredicate,
36
+ dimensions: readonly AnalysisDimensionDefinition[],
37
+ ): AnalysisPredicate {
38
+ const byId = new Map(dimensions.map((dimension) => [dimension.id, dimension]));
39
+ const issues: DiagnosticValidationIssue[] = [];
40
+ const stack: { value: AnalysisPredicate; path: string; depth: number }[] = [
41
+ { value: predicate, path: "$", depth: 1 },
42
+ ];
43
+ let nodes = 0;
44
+ while (stack.length > 0) {
45
+ const { value, path, depth } = stack.pop()!;
46
+ nodes += 1;
47
+ if (nodes > MAX_PREDICATE_NODES) {
48
+ issues.push({
49
+ domain: "definition",
50
+ code: "expression-limit",
51
+ path: "$",
52
+ message: `Predicate exceeds ${MAX_PREDICATE_NODES} nodes`,
53
+ });
54
+ break;
55
+ }
56
+ if (depth > MAX_PREDICATE_DEPTH) {
57
+ issues.push({
58
+ domain: "definition",
59
+ code: "expression-limit",
60
+ path,
61
+ message: `Predicate exceeds depth ${MAX_PREDICATE_DEPTH}`,
62
+ });
63
+ continue;
64
+ }
65
+ if ("terms" in value) {
66
+ if (value.terms.length === 0)
67
+ issues.push({
68
+ domain: "definition",
69
+ code: "invalid-configuration",
70
+ path: `${path}.terms`,
71
+ message: `${value.op} requires at least one term`,
72
+ });
73
+ value.terms.forEach((term, index) => stack.push({ value: term, path: `${path}.terms[${index}]`, depth: depth + 1 }));
74
+ continue;
75
+ }
76
+ if ("term" in value) {
77
+ stack.push({ value: value.term, path: `${path}.term`, depth: depth + 1 });
78
+ continue;
79
+ }
80
+ if (!("dimensionId" in value)) continue;
81
+ const dimension = byId.get(value.dimensionId);
82
+ if (dimension === undefined) {
83
+ issues.push({
84
+ domain: "definition",
85
+ code: "unknown-reference",
86
+ path: `${path}.dimensionId`,
87
+ message: `Predicate references unknown dimension ${value.dimensionId}`,
88
+ });
89
+ continue;
90
+ }
91
+ if (value.op === "eq" && !compatible(value.value, dimension))
92
+ issues.push({
93
+ domain: "definition",
94
+ code: "incompatible-semantics",
95
+ path: `${path}.value`,
96
+ message: `Value type ${scalarType(value.value)} is incompatible with ${dimension.type} dimension ${dimension.id}`,
97
+ });
98
+ if (value.op === "in")
99
+ value.values.forEach((item, index) => {
100
+ if (!compatible(item, dimension))
101
+ issues.push({
102
+ domain: "definition",
103
+ code: "incompatible-semantics",
104
+ path: `${path}.values[${index}]`,
105
+ message: `Value type ${scalarType(item)} is incompatible with ${dimension.type} dimension ${dimension.id}`,
106
+ });
107
+ });
108
+ if (value.op === "range") {
109
+ if (dimension.type !== "number" && dimension.type !== "date")
110
+ issues.push({
111
+ domain: "definition",
112
+ code: "incompatible-semantics",
113
+ path,
114
+ message: `Range requires a number or date dimension; ${dimension.id} is ${dimension.type}`,
115
+ });
116
+ for (const [field, endpoint] of [
117
+ ["minimum", value.minimum],
118
+ ["maximum", value.maximum],
119
+ ] as const)
120
+ if (
121
+ endpoint !== undefined &&
122
+ !compatible(endpoint, dimension)
123
+ )
124
+ issues.push({
125
+ domain: "definition",
126
+ code: "incompatible-semantics",
127
+ path: `${path}.${field}`,
128
+ message: `Range endpoint is incompatible with ${dimension.type} dimension ${dimension.id}`,
129
+ });
130
+ }
131
+ }
132
+ if (issues.length > 0) throw new DiagnosticValidationError(issues);
133
+ return snapshotDiagnosticJson(predicate);
134
+ }
135
+
136
+ function invert(value: AnalysisPredicateOutcome): AnalysisPredicateOutcome {
137
+ return value === "match" ? "no-match" : value === "no-match" ? "match" : "unknown";
138
+ }
139
+
140
+ function valuesOf(
141
+ dimensions: Readonly<Record<string, HistoricalScalar | readonly HistoricalScalar[]>>,
142
+ id: string,
143
+ ): readonly HistoricalScalar[] | undefined {
144
+ const value = dimensions[id];
145
+ return value === undefined ? undefined : Array.isArray(value) ? value : [value as HistoricalScalar];
146
+ }
147
+
148
+ function compareRange(
149
+ value: HistoricalScalar,
150
+ predicate: Extract<AnalysisPredicate, { op: "range" }>,
151
+ ): boolean {
152
+ if (typeof value !== "number" && typeof value !== "string") return false;
153
+ const compare = (left: number | string, right: number | string): number => {
154
+ if (typeof left !== typeof right) return Number.NaN;
155
+ if (typeof left === "number" && typeof right === "number")
156
+ return left === right ? 0 : left < right ? -1 : 1;
157
+ if (typeof left === "string" && typeof right === "string")
158
+ return left === right ? 0 : left < right ? -1 : 1;
159
+ return Number.NaN;
160
+ };
161
+ const lower =
162
+ predicate.minimum === undefined ||
163
+ (predicate.includeMinimum
164
+ ? compare(value, predicate.minimum) >= 0
165
+ : compare(value, predicate.minimum) > 0);
166
+ const upper =
167
+ predicate.maximum === undefined ||
168
+ (predicate.includeMaximum
169
+ ? compare(value, predicate.maximum) <= 0
170
+ : compare(value, predicate.maximum) < 0);
171
+ return lower && upper;
172
+ }
173
+
174
+ function observationDimensions(
175
+ observation: PreparedHistoricalObservation,
176
+ ): Readonly<Record<string, HistoricalScalar | readonly HistoricalScalar[]>> {
177
+ return {
178
+ ...observation.dimensions,
179
+ sourceNamespace: observation.sourceNamespace,
180
+ claimId: observation.claimId,
181
+ evaluationDate: observation.evaluationDate,
182
+ ...(observation.accidentDate === null ? {} : { accidentDate: observation.accidentDate }),
183
+ ...(observation.reportDate === null ? {} : { reportDate: observation.reportDate }),
184
+ ...(observation.status === null ? {} : { status: observation.status }),
185
+ ...(observation.relationships.claimantIds === undefined
186
+ ? {}
187
+ : { claimantIds: observation.relationships.claimantIds }),
188
+ ...(observation.relationships.occurrenceId === undefined
189
+ ? {}
190
+ : { occurrenceId: observation.relationships.occurrenceId }),
191
+ ...(observation.relationships.policyIds === undefined
192
+ ? {}
193
+ : { policyIds: observation.relationships.policyIds }),
194
+ ...(observation.relationships.coverageIds === undefined
195
+ ? {}
196
+ : { coverageIds: observation.relationships.coverageIds }),
197
+ };
198
+ }
199
+
200
+ export function evaluateAnalysisPredicate(
201
+ predicateInput: AnalysisPredicate,
202
+ dimensions: Readonly<Record<string, HistoricalScalar | readonly HistoricalScalar[]>>,
203
+ catalog: readonly AnalysisDimensionDefinition[],
204
+ ): AnalysisPredicateOutcome {
205
+ const predicate = validateAnalysisPredicate(predicateInput, catalog);
206
+ const evaluate = (value: AnalysisPredicate): AnalysisPredicateOutcome => {
207
+ if (!("dimensionId" in value)) {
208
+ if (value.op === "true") return "match";
209
+ if (value.op === "not") return invert(evaluate(value.term));
210
+ const terms = value.terms.map(evaluate);
211
+ return value.op === "and"
212
+ ? terms.includes("no-match")
213
+ ? "no-match"
214
+ : terms.includes("unknown")
215
+ ? "unknown"
216
+ : "match"
217
+ : terms.includes("match")
218
+ ? "match"
219
+ : terms.includes("unknown")
220
+ ? "unknown"
221
+ : "no-match";
222
+ }
223
+ const actual = valuesOf(dimensions, value.dimensionId);
224
+ if (value.op === "missing")
225
+ return (actual === undefined || actual.every((item) => item === null)) === value.isMissing
226
+ ? "match"
227
+ : "no-match";
228
+ if (actual === undefined || actual.every((item) => item === null)) return "unknown";
229
+ const observed = actual.filter((item) => item !== null);
230
+ if (value.op === "eq") return observed.some((item) => Object.is(item, value.value)) ? "match" : "no-match";
231
+ if (value.op === "in")
232
+ return observed.some((item) => value.values.some((expected) => Object.is(item, expected)))
233
+ ? "match"
234
+ : "no-match";
235
+ if (value.op === "range")
236
+ return observed.some((item) => compareRange(item, value)) ? "match" : "no-match";
237
+ return "no-match";
238
+ };
239
+ return evaluate(predicate);
240
+ }
241
+
242
+ function latestOnOrBefore(
243
+ history: readonly PreparedHistoricalObservation[],
244
+ asOfDate: string,
245
+ ): PreparedHistoricalObservation | undefined {
246
+ return history
247
+ .filter((observation) => observation.evaluationDate <= asOfDate)
248
+ .sort((left, right) =>
249
+ left.evaluationDate === right.evaluationDate
250
+ ? 0
251
+ : left.evaluationDate < right.evaluationDate
252
+ ? 1
253
+ : -1,
254
+ )[0];
255
+ }
256
+
257
+ export function evaluateAnalysisScope(input: {
258
+ readonly scope: AnalysisScopeDefinition;
259
+ readonly observation: PreparedHistoricalObservation;
260
+ readonly claimHistory: readonly PreparedHistoricalObservation[];
261
+ readonly dimensions: readonly AnalysisDimensionDefinition[];
262
+ }): AnalysisPopulationMembership {
263
+ const classification = input.scope.classification;
264
+ const asOfDate = classification.kind === "as-observed" ? null : classification.asOfDate;
265
+ const classified =
266
+ classification.kind === "as-observed"
267
+ ? input.observation
268
+ : latestOnOrBefore(input.claimHistory, classification.asOfDate);
269
+ if (classified === undefined)
270
+ return snapshotDiagnosticJson({
271
+ scopeId: input.scope.id,
272
+ sourceNamespace: input.observation.sourceNamespace,
273
+ claimId: input.observation.claimId,
274
+ evaluationDate: input.observation.evaluationDate,
275
+ outcome: "unknown",
276
+ classificationDate: asOfDate!,
277
+ finding: {
278
+ code: "missing-prerequisite",
279
+ capability: "typed-populations",
280
+ path: `$.scopes.${input.scope.id}`,
281
+ message: `No classification observation exists on or before ${asOfDate}`,
282
+ requiredFields: ["classification observation"],
283
+ },
284
+ });
285
+ const outcome = evaluateAnalysisPredicate(
286
+ input.scope.predicate,
287
+ observationDimensions(classified),
288
+ input.dimensions,
289
+ );
290
+ return snapshotDiagnosticJson({
291
+ scopeId: input.scope.id,
292
+ sourceNamespace: input.observation.sourceNamespace,
293
+ claimId: input.observation.claimId,
294
+ evaluationDate: input.observation.evaluationDate,
295
+ outcome,
296
+ classificationDate: classified.evaluationDate,
297
+ finding:
298
+ outcome !== "unknown"
299
+ ? null
300
+ : {
301
+ code: "missing-prerequisite",
302
+ capability: "typed-populations",
303
+ path: `$.scopes.${input.scope.id}.predicate`,
304
+ message: "Predicate result is unknown because a required dimension is missing",
305
+ },
306
+ });
307
+ }