@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.
- package/README.md +46 -2
- package/dist/analysisRecipe.d.ts +55 -0
- package/dist/analysisRecipe.d.ts.map +1 -0
- package/dist/analysisRecipe.js +627 -0
- package/dist/analysisRecipe.js.map +1 -0
- package/dist/classifications.d.ts +30 -0
- package/dist/classifications.d.ts.map +1 -0
- package/dist/classifications.js +207 -0
- package/dist/classifications.js.map +1 -0
- package/dist/customizationContracts.d.ts +749 -0
- package/dist/customizationContracts.d.ts.map +1 -0
- package/dist/customizationContracts.js +19 -0
- package/dist/customizationContracts.js.map +1 -0
- package/dist/descriptiveStatistics.d.ts +8 -0
- package/dist/descriptiveStatistics.d.ts.map +1 -0
- package/dist/descriptiveStatistics.js +283 -0
- package/dist/descriptiveStatistics.js.map +1 -0
- package/dist/diagnosticAggregation.d.ts.map +1 -1
- package/dist/diagnosticAggregation.js +16 -0
- package/dist/diagnosticAggregation.js.map +1 -1
- package/dist/diagnosticFreeze.d.ts +4 -0
- package/dist/diagnosticFreeze.d.ts.map +1 -0
- package/dist/diagnosticFreeze.js +37 -0
- package/dist/diagnosticFreeze.js.map +1 -0
- package/dist/diagnosticIdentityStream.d.ts +4 -0
- package/dist/diagnosticIdentityStream.d.ts.map +1 -1
- package/dist/diagnosticIdentityStream.js +32 -2
- package/dist/diagnosticIdentityStream.js.map +1 -1
- package/dist/diagnosticPreparation.d.ts +21 -0
- package/dist/diagnosticPreparation.d.ts.map +1 -1
- package/dist/diagnosticPreparation.js +231 -19
- package/dist/diagnosticPreparation.js.map +1 -1
- package/dist/diagnosticReview.d.ts.map +1 -1
- package/dist/diagnosticReview.js +43 -25
- package/dist/diagnosticReview.js.map +1 -1
- package/dist/diagnosticReviewSources.d.ts.map +1 -1
- package/dist/diagnosticReviewSources.js +25 -4
- package/dist/diagnosticReviewSources.js.map +1 -1
- package/dist/diagnosticReviewStore.d.ts +2 -0
- package/dist/diagnosticReviewStore.d.ts.map +1 -1
- package/dist/diagnosticReviewStore.js +110 -36
- package/dist/diagnosticReviewStore.js.map +1 -1
- package/dist/diagnosticRunner.d.ts.map +1 -1
- package/dist/diagnosticRunner.js +1 -8
- package/dist/diagnosticRunner.js.map +1 -1
- package/dist/exposureEarning.d.ts +12 -0
- package/dist/exposureEarning.d.ts.map +1 -0
- package/dist/exposureEarning.js +276 -0
- package/dist/exposureEarning.js.map +1 -0
- package/dist/exposureMovements.d.ts +8 -0
- package/dist/exposureMovements.d.ts.map +1 -0
- package/dist/exposureMovements.js +70 -0
- package/dist/exposureMovements.js.map +1 -0
- package/dist/exposureRevisions.d.ts +13 -0
- package/dist/exposureRevisions.d.ts.map +1 -0
- package/dist/exposureRevisions.js +161 -0
- package/dist/exposureRevisions.js.map +1 -0
- package/dist/financialPipeline.d.ts +14 -0
- package/dist/financialPipeline.d.ts.map +1 -0
- package/dist/financialPipeline.js +75 -0
- package/dist/financialPipeline.js.map +1 -0
- package/dist/financialTerms.d.ts +20 -0
- package/dist/financialTerms.d.ts.map +1 -0
- package/dist/financialTerms.js +205 -0
- package/dist/financialTerms.js.map +1 -0
- package/dist/fnvAccumulator.d.ts.map +1 -1
- package/dist/fnvAccumulator.js +11 -5
- package/dist/fnvAccumulator.js.map +1 -1
- package/dist/historyPreparation.d.ts +8 -0
- package/dist/historyPreparation.d.ts.map +1 -0
- package/dist/historyPreparation.js +557 -0
- package/dist/historyPreparation.js.map +1 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -2
- package/dist/index.js.map +1 -1
- package/dist/policyPeriods.d.ts +29 -0
- package/dist/policyPeriods.d.ts.map +1 -0
- package/dist/policyPeriods.js +322 -0
- package/dist/policyPeriods.js.map +1 -0
- package/dist/populations.d.ts +11 -0
- package/dist/populations.d.ts.map +1 -0
- package/dist/populations.js +264 -0
- package/dist/populations.js.map +1 -0
- package/dist/reservingAdapter.d.ts +16 -0
- package/dist/reservingAdapter.d.ts.map +1 -0
- package/dist/reservingAdapter.js +74 -0
- package/dist/reservingAdapter.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/analysisRecipe.ts +696 -0
- package/src/classifications.ts +231 -0
- package/src/customizationContracts.ts +763 -0
- package/src/descriptiveStatistics.ts +320 -0
- package/src/diagnosticAggregation.ts +16 -0
- package/src/diagnosticFreeze.ts +37 -0
- package/src/diagnosticIdentityStream.ts +37 -2
- package/src/diagnosticPreparation.ts +259 -26
- package/src/diagnosticReview.ts +49 -27
- package/src/diagnosticReviewSources.ts +22 -3
- package/src/diagnosticReviewStore.ts +139 -54
- package/src/diagnosticRunner.ts +1 -11
- package/src/exposureEarning.ts +314 -0
- package/src/exposureMovements.ts +78 -0
- package/src/exposureRevisions.ts +186 -0
- package/src/financialPipeline.ts +93 -0
- package/src/financialTerms.ts +263 -0
- package/src/fnvAccumulator.ts +11 -5
- package/src/historyPreparation.ts +645 -0
- package/src/index.ts +28 -1
- package/src/policyPeriods.ts +375 -0
- package/src/populations.ts +307 -0
- package/src/reservingAdapter.ts +98 -0
- package/src/version.ts +1 -1
|
@@ -20,6 +20,7 @@ import { DiagnosticValidationError } from "./types.js";
|
|
|
20
20
|
import {
|
|
21
21
|
createDiagnosticIdentityArray,
|
|
22
22
|
createDiagnosticIdentityObject,
|
|
23
|
+
createDiagnosticIdentityMixedObject,
|
|
23
24
|
createDiagnosticIdentityValue,
|
|
24
25
|
type DiagnosticIdentityDocument,
|
|
25
26
|
} from "./diagnosticIdentityStream.js";
|
|
@@ -140,8 +141,82 @@ interface State {
|
|
|
140
141
|
sourceLists: readonly Uint32Array[];
|
|
141
142
|
overflows: ReadonlyMap<number, readonly EncodedOverflow[]>;
|
|
142
143
|
controls: ReadonlyMap<number, Omit<Control, "sources">>;
|
|
144
|
+
selection?: {
|
|
145
|
+
readonly owners: readonly State[];
|
|
146
|
+
readonly ownerIds: Uint8Array;
|
|
147
|
+
readonly indices: Uint32Array;
|
|
148
|
+
};
|
|
143
149
|
}
|
|
144
150
|
const states = new WeakMap<object, State>();
|
|
151
|
+
function resolveRow(state: State, index: number): { state: State; index: number } {
|
|
152
|
+
while (state.selection) {
|
|
153
|
+
const selection = state.selection;
|
|
154
|
+
state = selection.owners[selection.ownerIds[index]!]!;
|
|
155
|
+
index = selection.indices[index]!;
|
|
156
|
+
}
|
|
157
|
+
return { state, index };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Private evidence projection: invariant source-local rows plus newly evaluated controls. */
|
|
161
|
+
export function selectCompactReviewSources(
|
|
162
|
+
original: CompactDiagnosticReviewEvaluations,
|
|
163
|
+
controls: CompactDiagnosticReviewEvaluations,
|
|
164
|
+
groups: ReadonlySet<string> | undefined,
|
|
165
|
+
origins?: ReadonlySet<string>,
|
|
166
|
+
cells?: ReadonlySet<string>,
|
|
167
|
+
): CompactDiagnosticReviewEvaluations {
|
|
168
|
+
const parent = stateOf(original);
|
|
169
|
+
const control = stateOf(controls);
|
|
170
|
+
if (parent.rules !== control.rules) invalid("Review definitions must share ownership");
|
|
171
|
+
const ownerIds: number[] = [];
|
|
172
|
+
const indices: number[] = [];
|
|
173
|
+
const counts = zeroCounts();
|
|
174
|
+
const cellMembership = new WeakMap<DiagnosticReviewCoordinate, boolean>();
|
|
175
|
+
const rules = parent.rules.map((rule, ruleIndex) => {
|
|
176
|
+
const ownerId = rule.kind === "control-total" || (cells !== undefined && rule.kind === "monotonic") ? 1 : 0;
|
|
177
|
+
const source = ownerId === 1 ? controls : original;
|
|
178
|
+
const sourceState = ownerId === 1 ? control : parent;
|
|
179
|
+
const range = source.rules[ruleIndex]!;
|
|
180
|
+
const start = indices.length;
|
|
181
|
+
const summary = zeroCounts();
|
|
182
|
+
for (let index = range.start; index < range.start + range.count; index++) {
|
|
183
|
+
const resolved = resolveRow(sourceState, index);
|
|
184
|
+
const chunk = resolved.state.chunks[Math.floor(resolved.index / CHUNK_SIZE)]!;
|
|
185
|
+
const row = resolved.index % CHUNK_SIZE;
|
|
186
|
+
if (ownerId === 0 && cells) {
|
|
187
|
+
const coordinate = resolved.state.coordinates[chunk.first[row]!]!;
|
|
188
|
+
let included = cellMembership.get(coordinate);
|
|
189
|
+
if (included === undefined) {
|
|
190
|
+
included = cells.has(JSON.stringify([coordinate.sourceGroup, coordinate.origin, coordinate.valuation]));
|
|
191
|
+
cellMembership.set(coordinate, included);
|
|
192
|
+
}
|
|
193
|
+
if (!included) continue;
|
|
194
|
+
}
|
|
195
|
+
if (ownerId === 0 && origins &&
|
|
196
|
+
!origins.has(resolved.state.coordinates[chunk.first[row]!]!.origin)) continue;
|
|
197
|
+
if (ownerId === 0 && groups) {
|
|
198
|
+
if (!groups.has(resolved.state.coordinates[chunk.first[row]!]!.sourceGroup)) continue;
|
|
199
|
+
if (rule.kind === "monotonic" &&
|
|
200
|
+
!groups.has(resolved.state.coordinates[chunk.second[row]!]!.sourceGroup)) continue;
|
|
201
|
+
}
|
|
202
|
+
assertCompactReviewCapacity(indices.length);
|
|
203
|
+
ownerIds.push(ownerId);
|
|
204
|
+
indices.push(index);
|
|
205
|
+
const status = effective(statuses[chunk.flags[row]! & 3]!, rule.severity,
|
|
206
|
+
resolved.state.overflows.get(resolved.index)?.length ?? 0);
|
|
207
|
+
increment(summary, status);
|
|
208
|
+
increment(counts, status);
|
|
209
|
+
}
|
|
210
|
+
return Object.freeze({ ruleId: rule.id, start, count: indices.length - start,
|
|
211
|
+
summary: Object.freeze(summary) });
|
|
212
|
+
});
|
|
213
|
+
const handle = Object.freeze({ count: indices.length, summary: Object.freeze(counts),
|
|
214
|
+
rules: Object.freeze(rules) }) as CompactDiagnosticReviewEvaluations;
|
|
215
|
+
states.set(handle, { ...parent, count: indices.length,
|
|
216
|
+
selection: { owners: [parent, control], ownerIds: Uint8Array.from(ownerIds),
|
|
217
|
+
indices: Uint32Array.from(indices) } });
|
|
218
|
+
return handle;
|
|
219
|
+
}
|
|
145
220
|
/** Private index guard: reject capacity overflow before any Uint32 assignment. */
|
|
146
221
|
export function assertCompactReviewCapacity(count: number, addition = 1): void {
|
|
147
222
|
if (
|
|
@@ -223,7 +298,10 @@ export function createCompactReviewBuilder(
|
|
|
223
298
|
const coordinates: DiagnosticReviewCoordinate[] = [];
|
|
224
299
|
const sourceLocations: DiagnosticSourceLocation[] = [];
|
|
225
300
|
const sourceLists: Uint32Array[] = [];
|
|
226
|
-
|
|
301
|
+
// Invocation-local tuple index: avoid allocating/serializing the same five
|
|
302
|
+
// coordinate fields for every rule evaluation. Never retained by State.
|
|
303
|
+
type CoordinateIndex = Map<string | number, CoordinateIndex | number>;
|
|
304
|
+
const coordinateIds: CoordinateIndex = new Map();
|
|
227
305
|
const sourceIds = new Map<string, number>();
|
|
228
306
|
// Only this evaluator invocation's snapshots may skip canonicalization.
|
|
229
307
|
// Merely freezing a caller array/object does not prove deep snapshot ownership.
|
|
@@ -238,18 +316,25 @@ export function createCompactReviewBuilder(
|
|
|
238
316
|
let count = 0;
|
|
239
317
|
let finished = false;
|
|
240
318
|
const coordinateId = (value: DiagnosticReviewCoordinate): number => {
|
|
241
|
-
|
|
319
|
+
let index = coordinateIds;
|
|
320
|
+
for (const key of [
|
|
242
321
|
value.sourceGroup,
|
|
243
322
|
value.origin,
|
|
244
323
|
value.valuation,
|
|
245
324
|
Object.is(value.developmentAge, -0) ? "-0" : value.developmentAge,
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
325
|
+
]) {
|
|
326
|
+
let next = index.get(key) as CoordinateIndex | undefined;
|
|
327
|
+
if (!next) {
|
|
328
|
+
next = new Map();
|
|
329
|
+
index.set(key, next);
|
|
330
|
+
}
|
|
331
|
+
index = next;
|
|
332
|
+
}
|
|
333
|
+
let id = index.get(value.ageUnit) as number | undefined;
|
|
249
334
|
if (id === undefined) {
|
|
250
335
|
assertCompactReviewCapacity(coordinates.length);
|
|
251
336
|
id = coordinates.length;
|
|
252
|
-
|
|
337
|
+
index.set(value.ageUnit, id);
|
|
253
338
|
coordinates.push(Object.freeze({ ...value }));
|
|
254
339
|
}
|
|
255
340
|
return id;
|
|
@@ -391,6 +476,8 @@ function summaryAt(
|
|
|
391
476
|
state: State,
|
|
392
477
|
index: number,
|
|
393
478
|
): DiagnosticReviewEvaluationSummary {
|
|
479
|
+
const outputIndex = index;
|
|
480
|
+
({ state, index } = resolveRow(state, index));
|
|
394
481
|
const chunk = state.chunks[Math.floor(index / CHUNK_SIZE)]!;
|
|
395
482
|
const row = index % CHUNK_SIZE;
|
|
396
483
|
const rule = state.rules[chunk.rule[row]!]!;
|
|
@@ -414,7 +501,7 @@ function summaryAt(
|
|
|
414
501
|
sourceCount,
|
|
415
502
|
};
|
|
416
503
|
return Object.freeze({
|
|
417
|
-
index,
|
|
504
|
+
index: outputIndex,
|
|
418
505
|
ruleId: rule.id,
|
|
419
506
|
ruleKind: rule.kind,
|
|
420
507
|
status,
|
|
@@ -444,8 +531,9 @@ export function getDiagnosticReviewEvaluation(
|
|
|
444
531
|
store: CompactDiagnosticReviewEvaluations,
|
|
445
532
|
index: number,
|
|
446
533
|
): DiagnosticReviewRuleEvaluation {
|
|
447
|
-
|
|
534
|
+
let state = stateOf(store);
|
|
448
535
|
checkedIndex(state, index);
|
|
536
|
+
({ state, index } = resolveRow(state, index));
|
|
449
537
|
const {
|
|
450
538
|
index: _index,
|
|
451
539
|
effectiveStatus: _effective,
|
|
@@ -498,13 +586,16 @@ export function getCompactDiagnosticReviewEvaluationsIdentityDocument(
|
|
|
498
586
|
store: CompactDiagnosticReviewEvaluations,
|
|
499
587
|
): DiagnosticIdentityDocument {
|
|
500
588
|
const state = stateOf(store);
|
|
501
|
-
const sources = (id: number) => {
|
|
589
|
+
const sources = (state: State, id: number) => {
|
|
502
590
|
const values = state.sourceLists[id]!;
|
|
503
591
|
return createDiagnosticIdentityArray(values.length, (index) =>
|
|
504
592
|
createDiagnosticIdentityValue(state.sourceLocations[values[index]!]!),
|
|
505
593
|
);
|
|
506
594
|
};
|
|
507
595
|
return createDiagnosticIdentityArray(state.count, (index) => {
|
|
596
|
+
const resolved = resolveRow(state, index);
|
|
597
|
+
const owner = resolved.state;
|
|
598
|
+
const ownerIndex = resolved.index;
|
|
508
599
|
const {
|
|
509
600
|
index: _index,
|
|
510
601
|
effectiveStatus: _effective,
|
|
@@ -513,45 +604,36 @@ export function getCompactDiagnosticReviewEvaluationsIdentityDocument(
|
|
|
513
604
|
...base
|
|
514
605
|
} = summaryAt(state, index);
|
|
515
606
|
const { sourceCount: _sources, ...scopeValues } = scope;
|
|
516
|
-
const chunk =
|
|
517
|
-
const row =
|
|
518
|
-
const rule =
|
|
519
|
-
const overflow =
|
|
520
|
-
return
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
607
|
+
const chunk = owner.chunks[Math.floor(ownerIndex / CHUNK_SIZE)]!;
|
|
608
|
+
const row = ownerIndex % CHUNK_SIZE;
|
|
609
|
+
const rule = owner.rules[chunk.rule[row]!]!;
|
|
610
|
+
const overflow = owner.overflows.get(ownerIndex) ?? [];
|
|
611
|
+
return createDiagnosticIdentityMixedObject(
|
|
612
|
+
{
|
|
613
|
+
...base,
|
|
614
|
+
...(rule.kind === "layer-order" ? { comparability: rule.comparability } : {}),
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
scope: createDiagnosticIdentityMixedObject(scopeValues, {
|
|
618
|
+
sources: sources(owner, chunk.sources[row]!),
|
|
619
|
+
}),
|
|
620
|
+
expressionOverflows: createDiagnosticIdentityArray(
|
|
621
|
+
overflow.length,
|
|
622
|
+
(overflowIndex) => {
|
|
623
|
+
const item = overflow[overflowIndex]!;
|
|
624
|
+
return createDiagnosticIdentityObject({
|
|
625
|
+
expressionPath: createDiagnosticIdentityValue(item.path),
|
|
626
|
+
coordinate: createDiagnosticIdentityValue(
|
|
627
|
+
item.coordinate === null
|
|
628
|
+
? null
|
|
629
|
+
: owner.coordinates[item.coordinate]!,
|
|
630
|
+
),
|
|
631
|
+
sources: sources(owner, item.sources),
|
|
632
|
+
});
|
|
633
|
+
},
|
|
533
634
|
),
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
expressionOverflows: createDiagnosticIdentityArray(
|
|
537
|
-
overflow.length,
|
|
538
|
-
(overflowIndex) => {
|
|
539
|
-
const item = overflow[overflowIndex]!;
|
|
540
|
-
return createDiagnosticIdentityObject({
|
|
541
|
-
expressionPath: createDiagnosticIdentityValue(item.path),
|
|
542
|
-
coordinate: createDiagnosticIdentityValue(
|
|
543
|
-
item.coordinate === null
|
|
544
|
-
? null
|
|
545
|
-
: state.coordinates[item.coordinate]!,
|
|
546
|
-
),
|
|
547
|
-
sources: sources(item.sources),
|
|
548
|
-
});
|
|
549
|
-
},
|
|
550
|
-
),
|
|
551
|
-
...(rule.kind === "layer-order"
|
|
552
|
-
? { comparability: createDiagnosticIdentityValue(rule.comparability) }
|
|
553
|
-
: {}),
|
|
554
|
-
});
|
|
635
|
+
},
|
|
636
|
+
);
|
|
555
637
|
});
|
|
556
638
|
}
|
|
557
639
|
function checkedQuery<T extends object>(
|
|
@@ -632,25 +714,27 @@ export function pageDiagnosticReviewEvaluations(
|
|
|
632
714
|
(checked.valuation === undefined ||
|
|
633
715
|
coordinate.valuation === checked.valuation);
|
|
634
716
|
for (let index = range.start; index < range.start + range.count; index++) {
|
|
635
|
-
const
|
|
636
|
-
const
|
|
637
|
-
const
|
|
717
|
+
const resolved = resolveRow(state, index);
|
|
718
|
+
const owner = resolved.state;
|
|
719
|
+
const chunk = owner.chunks[Math.floor(resolved.index / CHUNK_SIZE)]!;
|
|
720
|
+
const row = resolved.index % CHUNK_SIZE;
|
|
721
|
+
const rule = owner.rules[chunk.rule[row]!]!;
|
|
638
722
|
if (
|
|
639
723
|
checked.effectiveStatus !== undefined &&
|
|
640
724
|
checked.effectiveStatus !==
|
|
641
725
|
effective(
|
|
642
726
|
statuses[chunk.flags[row]! & 3]!,
|
|
643
727
|
rule.severity,
|
|
644
|
-
|
|
728
|
+
owner.overflows.get(resolved.index)?.length ?? 0,
|
|
645
729
|
)
|
|
646
730
|
)
|
|
647
731
|
continue;
|
|
648
732
|
if (
|
|
649
733
|
coordinateFilter &&
|
|
650
734
|
(rule.kind === "control-total" ||
|
|
651
|
-
(!matches(
|
|
735
|
+
(!matches(owner.coordinates[chunk.first[row]!]!) &&
|
|
652
736
|
(rule.kind !== "monotonic" ||
|
|
653
|
-
!matches(
|
|
737
|
+
!matches(owner.coordinates[chunk.second[row]!]!))))
|
|
654
738
|
)
|
|
655
739
|
continue;
|
|
656
740
|
if (total >= checked.offset && items.length < checked.limit)
|
|
@@ -664,8 +748,9 @@ export function pageDiagnosticReviewEvaluationSources(
|
|
|
664
748
|
index: number,
|
|
665
749
|
query: DiagnosticReviewSourceQuery = {},
|
|
666
750
|
): DiagnosticReviewPage<DiagnosticSourceLocation> {
|
|
667
|
-
|
|
751
|
+
let state = stateOf(store);
|
|
668
752
|
checkedIndex(state, index);
|
|
753
|
+
({ state, index } = resolveRow(state, index));
|
|
669
754
|
const checked = checkedQuery(query, ["offset", "limit", "overflowIndex"]);
|
|
670
755
|
const overflowIndex = checked.overflowIndex;
|
|
671
756
|
if (
|
package/src/diagnosticRunner.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { compareDiagnosticFindings } from "./diagnosticOrdering.js";
|
|
48
48
|
import { normalizeDiagnosticPeriod } from "./diagnosticPeriods.js";
|
|
49
49
|
import { normalizeDiagnosticSourceLocations } from "./diagnosticSourceOrdering.js";
|
|
50
|
+
import { freezeDiagnosticGraph as deepFreeze } from "./diagnosticFreeze.js";
|
|
50
51
|
import {
|
|
51
52
|
diagnosticJsonPreflight,
|
|
52
53
|
hasDiagnosticOwn,
|
|
@@ -163,17 +164,6 @@ function codeUnit(a: string, b: string): number {
|
|
|
163
164
|
function pointer(path: string, segment: string | number): string {
|
|
164
165
|
return `${path}/${String(segment).replaceAll("~", "~0").replaceAll("/", "~1")}`;
|
|
165
166
|
}
|
|
166
|
-
function deepFreeze<T>(
|
|
167
|
-
value: T,
|
|
168
|
-
seen = new WeakSet<object>(),
|
|
169
|
-
): DiagnosticDeepReadonly<T> {
|
|
170
|
-
if (value === null || typeof value !== "object" || seen.has(value))
|
|
171
|
-
return value as DiagnosticDeepReadonly<T>;
|
|
172
|
-
seen.add(value);
|
|
173
|
-
for (const child of Object.values(value as Record<string, unknown>))
|
|
174
|
-
deepFreeze(child, seen);
|
|
175
|
-
return Object.freeze(value) as DiagnosticDeepReadonly<T>;
|
|
176
|
-
}
|
|
177
167
|
function quantity(
|
|
178
168
|
measure: DiagnosticMeasureDefinition,
|
|
179
169
|
value: number | null,
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CustomizationCapabilityFinding,
|
|
3
|
+
CustomizationExposureObservation,
|
|
4
|
+
EarnedExposureAllocation,
|
|
5
|
+
ExposureAllocationResult,
|
|
6
|
+
ExposureAllocationTarget,
|
|
7
|
+
ExposureMeasureDefinitionV1,
|
|
8
|
+
ExposureScopeDefinition,
|
|
9
|
+
HistoricalScalar,
|
|
10
|
+
} from "./customizationContracts.js";
|
|
11
|
+
import { isRealIsoDate, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
|
|
12
|
+
import { daysBetweenIsoDates } from "./policyPeriods.js";
|
|
13
|
+
import { DiagnosticValidationError, type DiagnosticValidationIssue } from "./types.js";
|
|
14
|
+
|
|
15
|
+
function compareText(left: string, right: string): number {
|
|
16
|
+
return left === right ? 0 : left < right ? -1 : 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function dimensionsMatch(
|
|
20
|
+
source: Readonly<Record<string, HistoricalScalar>>,
|
|
21
|
+
target: Readonly<Record<string, HistoricalScalar>>,
|
|
22
|
+
keys: readonly string[],
|
|
23
|
+
): boolean {
|
|
24
|
+
return keys.every((key) => Object.is(source[key], target[key]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function overlapDays(
|
|
28
|
+
sourceStart: string,
|
|
29
|
+
sourceEnd: string,
|
|
30
|
+
targetStart: string,
|
|
31
|
+
targetEnd: string,
|
|
32
|
+
): number {
|
|
33
|
+
const start = sourceStart > targetStart ? sourceStart : targetStart;
|
|
34
|
+
const end = sourceEnd < targetEnd ? sourceEnd : targetEnd;
|
|
35
|
+
return end <= start ? 0 : daysBetweenIsoDates(start, end);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validateInput(
|
|
39
|
+
definition: ExposureMeasureDefinitionV1,
|
|
40
|
+
scope: ExposureScopeDefinition,
|
|
41
|
+
observation: CustomizationExposureObservation,
|
|
42
|
+
targets: readonly ExposureAllocationTarget[],
|
|
43
|
+
): void {
|
|
44
|
+
const issues: DiagnosticValidationIssue[] = [];
|
|
45
|
+
if (observation.measureId !== definition.id)
|
|
46
|
+
issues.push({
|
|
47
|
+
domain: "input",
|
|
48
|
+
code: "invalid-input-relationship",
|
|
49
|
+
path: "$.observation.measureId",
|
|
50
|
+
message: "Exposure observation does not match the measure definition",
|
|
51
|
+
});
|
|
52
|
+
if (definition.scopeId !== scope.id)
|
|
53
|
+
issues.push({
|
|
54
|
+
domain: "configuration",
|
|
55
|
+
code: "unknown-reference",
|
|
56
|
+
path: "$.definition.scopeId",
|
|
57
|
+
message: "Exposure definition does not reference the supplied scope",
|
|
58
|
+
});
|
|
59
|
+
if (observation.value !== null && !Number.isFinite(observation.value))
|
|
60
|
+
issues.push({
|
|
61
|
+
domain: "input",
|
|
62
|
+
code: "invalid-number",
|
|
63
|
+
path: "$.observation.value",
|
|
64
|
+
message: "Exposure value must be finite or null",
|
|
65
|
+
});
|
|
66
|
+
if (definition.sourceMeaning === "point-in-time") {
|
|
67
|
+
if (!isRealIsoDate(observation.pointDate))
|
|
68
|
+
issues.push({
|
|
69
|
+
domain: "input",
|
|
70
|
+
code: "missing-required",
|
|
71
|
+
path: "$.observation.pointDate",
|
|
72
|
+
message: "Point-in-time exposure requires pointDate",
|
|
73
|
+
});
|
|
74
|
+
} else if (
|
|
75
|
+
!isRealIsoDate(observation.startDate) ||
|
|
76
|
+
!isRealIsoDate(observation.endDate) ||
|
|
77
|
+
observation.startDate >= observation.endDate
|
|
78
|
+
)
|
|
79
|
+
issues.push({
|
|
80
|
+
domain: "input",
|
|
81
|
+
code: "invalid-period",
|
|
82
|
+
path: "$.observation",
|
|
83
|
+
message: "Interval exposure requires startDate before endDate",
|
|
84
|
+
});
|
|
85
|
+
targets.forEach((target, index) => {
|
|
86
|
+
if (!isRealIsoDate(target.startDate) || !isRealIsoDate(target.endDate) || target.startDate >= target.endDate)
|
|
87
|
+
issues.push({
|
|
88
|
+
domain: "configuration",
|
|
89
|
+
code: "invalid-period",
|
|
90
|
+
path: `$.targets[${index}]`,
|
|
91
|
+
message: "Allocation target requires startDate before endDate",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
if (issues.length > 0) throw new DiagnosticValidationError(issues);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Earns and allocates one source exposure under explicit interval, scope, and
|
|
99
|
+
* allocation meanings. It never converts point-in-time stock into a flow.
|
|
100
|
+
*/
|
|
101
|
+
export function earnAndAllocateExposure(input: {
|
|
102
|
+
readonly definition: ExposureMeasureDefinitionV1;
|
|
103
|
+
readonly scope: ExposureScopeDefinition;
|
|
104
|
+
readonly observation: CustomizationExposureObservation;
|
|
105
|
+
readonly targets: readonly ExposureAllocationTarget[];
|
|
106
|
+
}): ExposureAllocationResult {
|
|
107
|
+
validateInput(input.definition, input.scope, input.observation, input.targets);
|
|
108
|
+
const findings: CustomizationCapabilityFinding[] = [];
|
|
109
|
+
if (input.observation.value === null)
|
|
110
|
+
return snapshotDiagnosticJson({
|
|
111
|
+
measureId: input.definition.id,
|
|
112
|
+
sourceValue: null,
|
|
113
|
+
allocations: [],
|
|
114
|
+
allocatedValue: 0,
|
|
115
|
+
unallocatedValue: null,
|
|
116
|
+
findings: [
|
|
117
|
+
{
|
|
118
|
+
code: "missing-prerequisite",
|
|
119
|
+
capability: "exposure-earning",
|
|
120
|
+
path: `$.exposures.${input.observation.id}.value`,
|
|
121
|
+
message: "Missing source exposure cannot be allocated",
|
|
122
|
+
requiredFields: ["value"],
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
const missingPopulationKeys = input.scope.populationKeys.filter(
|
|
127
|
+
(key) => !Object.hasOwn(input.observation.dimensions, key),
|
|
128
|
+
);
|
|
129
|
+
if (missingPopulationKeys.length > 0)
|
|
130
|
+
return snapshotDiagnosticJson({
|
|
131
|
+
measureId: input.definition.id,
|
|
132
|
+
sourceValue: input.observation.value,
|
|
133
|
+
allocations: [],
|
|
134
|
+
allocatedValue: 0,
|
|
135
|
+
unallocatedValue: input.observation.value,
|
|
136
|
+
findings: [
|
|
137
|
+
{
|
|
138
|
+
code: "missing-prerequisite",
|
|
139
|
+
capability: "exposure-earning",
|
|
140
|
+
path: `$.exposures.${input.observation.id}.dimensions`,
|
|
141
|
+
message: `Exposure observation lacks population keys: ${missingPopulationKeys.join(", ")}`,
|
|
142
|
+
requiredFields: missingPopulationKeys.map((key) => `dimensions.${key}`),
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
if (input.definition.sourceMeaning === "point-in-time")
|
|
147
|
+
return snapshotDiagnosticJson({
|
|
148
|
+
measureId: input.definition.id,
|
|
149
|
+
sourceValue: input.observation.value,
|
|
150
|
+
allocations: [],
|
|
151
|
+
allocatedValue: 0,
|
|
152
|
+
unallocatedValue: input.observation.value,
|
|
153
|
+
findings: [
|
|
154
|
+
{
|
|
155
|
+
code: "unsupported-operation",
|
|
156
|
+
capability: "exposure-earning",
|
|
157
|
+
path: `$.exposures.${input.observation.id}`,
|
|
158
|
+
message: "Point-in-time exposure cannot be allocated as additive interval exposure",
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
const matching = input.targets.filter((target) =>
|
|
163
|
+
dimensionsMatch(input.observation.dimensions, target.dimensions, input.scope.populationKeys),
|
|
164
|
+
);
|
|
165
|
+
if (matching.length === 0)
|
|
166
|
+
findings.push({
|
|
167
|
+
code: "missing-prerequisite",
|
|
168
|
+
capability: "exposure-earning",
|
|
169
|
+
path: `$.exposures.${input.observation.id}`,
|
|
170
|
+
message: "No allocation target matches the declared exposure population keys",
|
|
171
|
+
});
|
|
172
|
+
let allocations: EarnedExposureAllocation[] = [];
|
|
173
|
+
const value = input.observation.value;
|
|
174
|
+
const sourceStart = input.observation.startDate!;
|
|
175
|
+
const sourceEnd = input.observation.endDate!;
|
|
176
|
+
if (input.definition.allocation.kind === "exact-match") {
|
|
177
|
+
const exact = matching.filter(
|
|
178
|
+
(target) => target.startDate === sourceStart && target.endDate === sourceEnd,
|
|
179
|
+
);
|
|
180
|
+
if (exact.length === 1)
|
|
181
|
+
allocations = [
|
|
182
|
+
{
|
|
183
|
+
sourceObservationId: input.observation.id,
|
|
184
|
+
targetId: exact[0]!.id,
|
|
185
|
+
value,
|
|
186
|
+
method: "exact-match",
|
|
187
|
+
overlapDays: daysBetweenIsoDates(sourceStart, sourceEnd),
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
else if (exact.length > 1)
|
|
191
|
+
findings.push({
|
|
192
|
+
code: "ambiguous-attribution",
|
|
193
|
+
capability: "exposure-earning",
|
|
194
|
+
path: `$.exposures.${input.observation.id}`,
|
|
195
|
+
message: "Multiple exact allocation targets match the source exposure",
|
|
196
|
+
});
|
|
197
|
+
} else if (input.definition.allocation.kind === "supplied-weights") {
|
|
198
|
+
const weightId = input.definition.allocation.weightMeasureId;
|
|
199
|
+
const weighted = matching.map((target) => ({ target, weight: target.weights?.[weightId] }));
|
|
200
|
+
if (weighted.some(({ weight }) => weight === undefined || !Number.isFinite(weight) || weight < 0))
|
|
201
|
+
findings.push({
|
|
202
|
+
code: "missing-prerequisite",
|
|
203
|
+
capability: "exposure-earning",
|
|
204
|
+
path: `$.targets.weights.${weightId}`,
|
|
205
|
+
message: "Supplied-weight allocation requires finite nonnegative weights on every matching target",
|
|
206
|
+
requiredFields: [`weights.${weightId}`],
|
|
207
|
+
});
|
|
208
|
+
else {
|
|
209
|
+
const total = weighted.reduce((sum, item) => sum + item.weight!, 0);
|
|
210
|
+
if (!(total > 0))
|
|
211
|
+
findings.push({
|
|
212
|
+
code: "missing-prerequisite",
|
|
213
|
+
capability: "exposure-earning",
|
|
214
|
+
path: `$.targets.weights.${weightId}`,
|
|
215
|
+
message: "Supplied allocation weights must have a positive total",
|
|
216
|
+
});
|
|
217
|
+
else
|
|
218
|
+
allocations = weighted.map(({ target, weight }) => ({
|
|
219
|
+
sourceObservationId: input.observation.id,
|
|
220
|
+
targetId: target.id,
|
|
221
|
+
value: value * (weight! / total),
|
|
222
|
+
method: "supplied-weights" as const,
|
|
223
|
+
overlapDays: null,
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
const sourceDays = daysBetweenIsoDates(sourceStart, sourceEnd);
|
|
228
|
+
const overlaps = matching
|
|
229
|
+
.map((target) => ({ target, days: overlapDays(sourceStart, sourceEnd, target.startDate, target.endDate) }))
|
|
230
|
+
.filter(({ days }) => days > 0);
|
|
231
|
+
if (overlaps.reduce((total, item) => total + item.days, 0) > sourceDays) {
|
|
232
|
+
findings.push({
|
|
233
|
+
code: "ambiguous-attribution",
|
|
234
|
+
capability: "exposure-earning",
|
|
235
|
+
path: `$.exposures.${input.observation.id}`,
|
|
236
|
+
message: "Overlapping allocation targets would duplicate source exposure",
|
|
237
|
+
});
|
|
238
|
+
} else
|
|
239
|
+
if (
|
|
240
|
+
input.definition.sourceMeaning === "already-earned" ||
|
|
241
|
+
input.definition.earning.kind === "supplied"
|
|
242
|
+
)
|
|
243
|
+
findings.push({
|
|
244
|
+
code: "unsupported-operation",
|
|
245
|
+
capability: "exposure-earning",
|
|
246
|
+
path: `$.exposures.${input.observation.id}`,
|
|
247
|
+
message: "Already-earned/supplied exposure requires exact matching or supplied allocation weights",
|
|
248
|
+
});
|
|
249
|
+
else if (input.definition.earning.kind === "weighted") {
|
|
250
|
+
const weightId = input.definition.earning.scheduleId;
|
|
251
|
+
const weighted = overlaps.map(({ target, days }) => ({
|
|
252
|
+
target,
|
|
253
|
+
days,
|
|
254
|
+
weight: target.weights?.[weightId],
|
|
255
|
+
}));
|
|
256
|
+
if (weighted.some(({ weight }) => weight === undefined || !Number.isFinite(weight) || weight < 0))
|
|
257
|
+
findings.push({
|
|
258
|
+
code: "missing-prerequisite",
|
|
259
|
+
capability: "exposure-earning",
|
|
260
|
+
path: `$.targets.weights.${weightId}`,
|
|
261
|
+
message: "Weighted earning requires finite nonnegative schedule weights on every overlapping target",
|
|
262
|
+
requiredFields: [`weights.${weightId}`],
|
|
263
|
+
});
|
|
264
|
+
else {
|
|
265
|
+
const total = weighted.reduce((sum, item) => sum + item.weight!, 0);
|
|
266
|
+
if (!(total > 0))
|
|
267
|
+
findings.push({
|
|
268
|
+
code: "missing-prerequisite",
|
|
269
|
+
capability: "exposure-earning",
|
|
270
|
+
path: `$.targets.weights.${weightId}`,
|
|
271
|
+
message: "Weighted earning schedule must have a positive total",
|
|
272
|
+
});
|
|
273
|
+
else
|
|
274
|
+
allocations = weighted.map(({ target, days, weight }) => ({
|
|
275
|
+
sourceObservationId: input.observation.id,
|
|
276
|
+
targetId: target.id,
|
|
277
|
+
value: value * (weight! / total),
|
|
278
|
+
method: "weighted-schedule" as const,
|
|
279
|
+
overlapDays: days,
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
} else
|
|
283
|
+
allocations = overlaps.map(({ target, days }) => ({
|
|
284
|
+
sourceObservationId: input.observation.id,
|
|
285
|
+
targetId: target.id,
|
|
286
|
+
value: value * (days / sourceDays),
|
|
287
|
+
method:
|
|
288
|
+
input.definition.earning.kind === "uniform-daily"
|
|
289
|
+
? ("uniform-daily" as const)
|
|
290
|
+
: ("time-overlap" as const),
|
|
291
|
+
overlapDays: days,
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
allocations.sort((left, right) => compareText(left.targetId, right.targetId));
|
|
295
|
+
const allocatedValue = allocations.reduce((sum, allocation) => sum + allocation.value, 0);
|
|
296
|
+
let unallocatedValue = value - allocatedValue;
|
|
297
|
+
const tolerance = Math.max(1, Math.abs(value)) * Number.EPSILON * 8;
|
|
298
|
+
if (Math.abs(unallocatedValue) <= tolerance) unallocatedValue = 0;
|
|
299
|
+
if (unallocatedValue !== 0)
|
|
300
|
+
findings.push({
|
|
301
|
+
code: "missing-prerequisite",
|
|
302
|
+
capability: "exposure-earning",
|
|
303
|
+
path: `$.exposures.${input.observation.id}`,
|
|
304
|
+
message: `Exposure leaves ${unallocatedValue} ${input.definition.unit} unallocated`,
|
|
305
|
+
});
|
|
306
|
+
return snapshotDiagnosticJson({
|
|
307
|
+
measureId: input.definition.id,
|
|
308
|
+
sourceValue: value,
|
|
309
|
+
allocations,
|
|
310
|
+
allocatedValue,
|
|
311
|
+
unallocatedValue,
|
|
312
|
+
findings,
|
|
313
|
+
});
|
|
314
|
+
}
|