@actuarial-ts/core 0.7.1 → 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 +15 -4
  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
@@ -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
- const coordinateIds = new Map<string, number>();
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
- const key = JSON.stringify([
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
- value.ageUnit,
247
- ]);
248
- let id = coordinateIds.get(key);
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
- coordinateIds.set(key, id);
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
- const state = stateOf(store);
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 = state.chunks[Math.floor(index / CHUNK_SIZE)]!;
517
- const row = index % CHUNK_SIZE;
518
- const rule = state.rules[chunk.rule[row]!]!;
519
- const overflow = state.overflows.get(index) ?? [];
520
- return createDiagnosticIdentityObject({
521
- ...Object.fromEntries(
522
- Object.entries(base).map(([key, value]) => [
523
- key,
524
- createDiagnosticIdentityValue(value),
525
- ]),
526
- ),
527
- scope: createDiagnosticIdentityObject({
528
- ...Object.fromEntries(
529
- Object.entries(scopeValues).map(([key, value]) => [
530
- key,
531
- createDiagnosticIdentityValue(value),
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
- sources: sources(chunk.sources[row]!),
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 chunk = state.chunks[Math.floor(index / CHUNK_SIZE)]!;
636
- const row = index % CHUNK_SIZE;
637
- const rule = state.rules[chunk.rule[row]!]!;
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
- state.overflows.get(index)?.length ?? 0,
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(state.coordinates[chunk.first[row]!]!) &&
735
+ (!matches(owner.coordinates[chunk.first[row]!]!) &&
652
736
  (rule.kind !== "monotonic" ||
653
- !matches(state.coordinates[chunk.second[row]!]!))))
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
- const state = stateOf(store);
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 (
@@ -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
+ }