@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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CustomizationCapabilityFinding,
|
|
3
|
+
CustomizationExposureObservation,
|
|
4
|
+
ExposureLevelSchedule,
|
|
5
|
+
ExposureLevelScheduleResult,
|
|
6
|
+
} from "./customizationContracts.js";
|
|
7
|
+
import { isRealIsoDate, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
|
|
8
|
+
import { daysBetweenIsoDates } from "./policyPeriods.js";
|
|
9
|
+
import { DiagnosticValidationError, type DiagnosticValidationIssue } from "./types.js";
|
|
10
|
+
|
|
11
|
+
function finding(message: string): CustomizationCapabilityFinding {
|
|
12
|
+
return { code: "ambiguous-attribution", capability: "exposure-earning", path: "$.movements", message };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Converts a declared daily exposure level, midterm replacements, and
|
|
17
|
+
* cancellation into nonoverlapping additive interval observations. A movement
|
|
18
|
+
* replaces the prior level; it is never interpreted as an undeclared delta.
|
|
19
|
+
*/
|
|
20
|
+
export function materializeExposureLevelSchedule(schedule: ExposureLevelSchedule): ExposureLevelScheduleResult {
|
|
21
|
+
const issues: DiagnosticValidationIssue[] = [];
|
|
22
|
+
if (!isRealIsoDate(schedule.startDate) || !isRealIsoDate(schedule.endDate) || schedule.startDate >= schedule.endDate)
|
|
23
|
+
issues.push({ domain: "input", code: "invalid-period", path: "$.schedule", message: "Exposure level schedule requires startDate before endDate" });
|
|
24
|
+
if (!Number.isFinite(schedule.initialDailyLevel) || schedule.initialDailyLevel < 0)
|
|
25
|
+
issues.push({ domain: "input", code: "invalid-number", path: "$.initialDailyLevel", message: "Initial daily level must be finite and nonnegative" });
|
|
26
|
+
for (const [index, movement] of schedule.movements.entries()) {
|
|
27
|
+
if (!isRealIsoDate(movement.effectiveDate) || movement.effectiveDate < schedule.startDate || movement.effectiveDate >= schedule.endDate)
|
|
28
|
+
issues.push({ domain: "input", code: "invalid-period", path: `$.movements[${index}].effectiveDate`, message: "Movement date must fall within the start-inclusive/end-exclusive schedule" });
|
|
29
|
+
if (movement.kind === "set-daily-level" && (!Number.isFinite(movement.dailyLevel) || movement.dailyLevel < 0))
|
|
30
|
+
issues.push({ domain: "input", code: "invalid-number", path: `$.movements[${index}].dailyLevel`, message: "Replacement daily level must be finite and nonnegative" });
|
|
31
|
+
}
|
|
32
|
+
if (issues.length > 0) throw new DiagnosticValidationError(issues);
|
|
33
|
+
const ordered = [...schedule.movements].sort((left, right) =>
|
|
34
|
+
left.effectiveDate === right.effectiveDate
|
|
35
|
+
? left.id.localeCompare(right.id)
|
|
36
|
+
: left.effectiveDate.localeCompare(right.effectiveDate),
|
|
37
|
+
);
|
|
38
|
+
for (let index = 1; index < ordered.length; index += 1)
|
|
39
|
+
if (ordered[index - 1]!.effectiveDate === ordered[index]!.effectiveDate)
|
|
40
|
+
return snapshotDiagnosticJson({
|
|
41
|
+
status: "unavailable",
|
|
42
|
+
observations: [],
|
|
43
|
+
totalValue: null,
|
|
44
|
+
findings: [finding(`Multiple exposure movements are effective on ${ordered[index]!.effectiveDate}`)],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let cursor = schedule.startDate;
|
|
48
|
+
let level = schedule.initialDailyLevel;
|
|
49
|
+
let segment = 0;
|
|
50
|
+
const observations: CustomizationExposureObservation[] = [];
|
|
51
|
+
const emit = (endDate: string): void => {
|
|
52
|
+
if (cursor === endDate) return;
|
|
53
|
+
observations.push({
|
|
54
|
+
id: `${schedule.id}:segment-${segment}`,
|
|
55
|
+
measureId: schedule.measureId,
|
|
56
|
+
value: level * daysBetweenIsoDates(cursor, endDate),
|
|
57
|
+
startDate: cursor,
|
|
58
|
+
endDate,
|
|
59
|
+
...(schedule.evaluationDate === undefined ? {} : { evaluationDate: schedule.evaluationDate }),
|
|
60
|
+
dimensions: schedule.dimensions,
|
|
61
|
+
revisionId: schedule.revisionId,
|
|
62
|
+
source: schedule.source,
|
|
63
|
+
});
|
|
64
|
+
segment += 1;
|
|
65
|
+
cursor = endDate;
|
|
66
|
+
};
|
|
67
|
+
for (const movement of ordered) {
|
|
68
|
+
emit(movement.effectiveDate);
|
|
69
|
+
level = movement.kind === "cancel" ? 0 : movement.dailyLevel;
|
|
70
|
+
}
|
|
71
|
+
emit(schedule.endDate);
|
|
72
|
+
return snapshotDiagnosticJson({
|
|
73
|
+
status: "available",
|
|
74
|
+
observations,
|
|
75
|
+
totalValue: observations.reduce((total, observation) => total + (observation.value ?? 0), 0),
|
|
76
|
+
findings: [],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { canonicalJson, fnv1a64 } from "./canonical.js";
|
|
2
|
+
import type {
|
|
3
|
+
CustomizationCapabilityFinding,
|
|
4
|
+
CustomizationExposureObservation,
|
|
5
|
+
ExposureRevisionDisposition,
|
|
6
|
+
ExposureRevisionSelection,
|
|
7
|
+
ExposureScopeDefinition,
|
|
8
|
+
ExposureValuationSelection,
|
|
9
|
+
SelectedExposureRevisions,
|
|
10
|
+
} from "./customizationContracts.js";
|
|
11
|
+
import { isRealIsoDate, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
|
|
12
|
+
import { DiagnosticValidationError, type DiagnosticValidationIssue } from "./types.js";
|
|
13
|
+
|
|
14
|
+
function correctedAt(observation: CustomizationExposureObservation): string | undefined {
|
|
15
|
+
return observation.revision?.correctedAt;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function orderValue(
|
|
19
|
+
observation: CustomizationExposureObservation,
|
|
20
|
+
precedence: ExposureRevisionSelection["revisionPrecedence"],
|
|
21
|
+
): number | string | undefined {
|
|
22
|
+
return precedence === "sequence" ? observation.revision?.sequence : correctedAt(observation);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function compareOrder(
|
|
26
|
+
left: CustomizationExposureObservation,
|
|
27
|
+
right: CustomizationExposureObservation,
|
|
28
|
+
precedence: ExposureRevisionSelection["revisionPrecedence"],
|
|
29
|
+
): number {
|
|
30
|
+
const a = orderValue(left, precedence);
|
|
31
|
+
const b = orderValue(right, precedence);
|
|
32
|
+
if (a === undefined || b === undefined) return 0;
|
|
33
|
+
if (a === b) return left.revisionId.localeCompare(right.revisionId);
|
|
34
|
+
return a < b ? -1 : 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function economic(observation: CustomizationExposureObservation): unknown {
|
|
38
|
+
const { source: _source, revisionId: _revisionId, revision: _revision, ...value } = observation;
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Selects immutable exposure revisions without allowing later corrections into an as-known run. */
|
|
43
|
+
export function selectExposureRevisions(input: {
|
|
44
|
+
readonly observations: readonly CustomizationExposureObservation[];
|
|
45
|
+
readonly selection: ExposureRevisionSelection;
|
|
46
|
+
}): SelectedExposureRevisions {
|
|
47
|
+
const issues: DiagnosticValidationIssue[] = [];
|
|
48
|
+
if (
|
|
49
|
+
input.selection.revisionView.kind === "as-known" &&
|
|
50
|
+
!isRealIsoDate(input.selection.revisionView.knowledgeCutoff.slice(0, 10))
|
|
51
|
+
)
|
|
52
|
+
issues.push({
|
|
53
|
+
domain: "configuration",
|
|
54
|
+
code: "invalid-period",
|
|
55
|
+
path: "$.selection.revisionView.knowledgeCutoff",
|
|
56
|
+
message: "Exposure knowledge cutoff must begin with a real Gregorian date",
|
|
57
|
+
});
|
|
58
|
+
input.observations.forEach((observation, index) => {
|
|
59
|
+
const revision = observation.revision;
|
|
60
|
+
if (input.selection.revisionPrecedence === "sequence" && revision !== undefined && revision.sequence === undefined)
|
|
61
|
+
issues.push({ domain: "input", code: "missing-required", path: `$.observations[${index}].revision.sequence`, message: "Sequence precedence requires sequence on every revisioned exposure observation" });
|
|
62
|
+
if (input.selection.revisionPrecedence === "corrected-at" && revision !== undefined && revision.correctedAt === undefined)
|
|
63
|
+
issues.push({ domain: "input", code: "missing-required", path: `$.observations[${index}].revision.correctedAt`, message: "Corrected-at precedence requires correctedAt on every revisioned exposure observation" });
|
|
64
|
+
});
|
|
65
|
+
if (issues.length > 0) throw new DiagnosticValidationError(issues);
|
|
66
|
+
|
|
67
|
+
const groups = new Map<string, CustomizationExposureObservation[]>();
|
|
68
|
+
for (const observation of input.observations) {
|
|
69
|
+
const groupKey = canonicalJson([observation.id, observation.evaluationDate ?? null]);
|
|
70
|
+
const values = groups.get(groupKey) ?? [];
|
|
71
|
+
values.push(observation);
|
|
72
|
+
groups.set(groupKey, values);
|
|
73
|
+
}
|
|
74
|
+
const selected: CustomizationExposureObservation[] = [];
|
|
75
|
+
const dispositions: ExposureRevisionDisposition[] = [];
|
|
76
|
+
const findings: CustomizationCapabilityFinding[] = [];
|
|
77
|
+
for (const [, group] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
|
|
78
|
+
const observationId = group[0]!.id;
|
|
79
|
+
const evaluationDate = group[0]!.evaluationDate;
|
|
80
|
+
const disposition = (revisionId: string, value: ExposureRevisionDisposition["disposition"]): ExposureRevisionDisposition => ({
|
|
81
|
+
observationId,
|
|
82
|
+
...(evaluationDate === undefined ? {} : { evaluationDate }),
|
|
83
|
+
revisionId,
|
|
84
|
+
disposition: value,
|
|
85
|
+
});
|
|
86
|
+
const eligible = group.filter((observation) => {
|
|
87
|
+
if (input.selection.revisionView.kind !== "as-known") return true;
|
|
88
|
+
const known = correctedAt(observation);
|
|
89
|
+
const include = known === undefined || known <= input.selection.revisionView.knowledgeCutoff;
|
|
90
|
+
if (!include) dispositions.push(disposition(observation.revisionId, "after-knowledge-cutoff"));
|
|
91
|
+
return include;
|
|
92
|
+
});
|
|
93
|
+
if (eligible.length === 0) continue;
|
|
94
|
+
eligible.sort((a, b) => compareOrder(a, b, input.selection.revisionPrecedence));
|
|
95
|
+
const winner = eligible.at(-1)!;
|
|
96
|
+
const sameOrder = eligible.filter((candidate) =>
|
|
97
|
+
orderValue(candidate, input.selection.revisionPrecedence) === orderValue(winner, input.selection.revisionPrecedence),
|
|
98
|
+
);
|
|
99
|
+
const distinct = new Set(sameOrder.map((candidate) => canonicalJson(economic(candidate))));
|
|
100
|
+
if (distinct.size > 1) {
|
|
101
|
+
sameOrder.forEach((candidate) => dispositions.push(disposition(candidate.revisionId, "conflict")));
|
|
102
|
+
findings.push({
|
|
103
|
+
code: "invalid-configuration",
|
|
104
|
+
capability: "exposure-earning",
|
|
105
|
+
path: `$.exposures.${observationId}`,
|
|
106
|
+
message: "Exposure revisions at the selected precedence conflict",
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
for (const candidate of eligible) {
|
|
111
|
+
if (candidate === winner) continue;
|
|
112
|
+
dispositions.push(disposition(candidate.revisionId, sameOrder.includes(candidate) ? "duplicate" : "superseded"));
|
|
113
|
+
}
|
|
114
|
+
if (winner.revision?.action === "delete")
|
|
115
|
+
dispositions.push(disposition(winner.revisionId, "deleted"));
|
|
116
|
+
else {
|
|
117
|
+
dispositions.push(disposition(winner.revisionId, "selected"));
|
|
118
|
+
selected.push(winner);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const economicInput = {
|
|
122
|
+
selection: input.selection,
|
|
123
|
+
observations: selected.map(economic),
|
|
124
|
+
};
|
|
125
|
+
const calculationRevisionId = `fnv1a64-jcs-v1:${fnv1a64(canonicalJson(economicInput))}`;
|
|
126
|
+
const lineageRevisionId = `fnv1a64-jcs-v1:${fnv1a64(canonicalJson({
|
|
127
|
+
calculationRevisionId,
|
|
128
|
+
selected,
|
|
129
|
+
dispositions,
|
|
130
|
+
}))}`;
|
|
131
|
+
return snapshotDiagnosticJson({
|
|
132
|
+
observations: selected,
|
|
133
|
+
dispositions,
|
|
134
|
+
calculationRevisionId,
|
|
135
|
+
lineageRevisionId,
|
|
136
|
+
findings,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Applies the declared origin-static or valuation-specific exposure view. */
|
|
141
|
+
export function selectExposureValuation(input: {
|
|
142
|
+
readonly observations: readonly CustomizationExposureObservation[];
|
|
143
|
+
readonly scope: ExposureScopeDefinition;
|
|
144
|
+
readonly evaluationDate: string;
|
|
145
|
+
}): ExposureValuationSelection {
|
|
146
|
+
const mode = input.scope.valuationSelection ?? "origin-static";
|
|
147
|
+
const dated = input.observations.filter((observation) => observation.evaluationDate !== undefined);
|
|
148
|
+
const undated = input.observations.filter((observation) => observation.evaluationDate === undefined);
|
|
149
|
+
if (mode === "origin-static") {
|
|
150
|
+
if (dated.length > 0)
|
|
151
|
+
return snapshotDiagnosticJson({ observations: [], findings: [{
|
|
152
|
+
code: "incompatible-semantics", capability: "exposure-earning", path: `$.scopes.${input.scope.id}.valuationSelection`,
|
|
153
|
+
message: "Origin-static exposure cannot contain valuation-specific observations",
|
|
154
|
+
}] });
|
|
155
|
+
return snapshotDiagnosticJson({ observations: undated, findings: [] });
|
|
156
|
+
}
|
|
157
|
+
if (undated.length > 0)
|
|
158
|
+
return snapshotDiagnosticJson({ observations: [], findings: [{
|
|
159
|
+
code: "missing-prerequisite", capability: "exposure-earning", path: `$.scopes.${input.scope.id}.valuationSelection`,
|
|
160
|
+
message: "Valuation-specific exposure requires evaluationDate on every observation", requiredFields: ["evaluationDate"],
|
|
161
|
+
}] });
|
|
162
|
+
if (mode === "exact") {
|
|
163
|
+
const selected = dated.filter((observation) => observation.evaluationDate === input.evaluationDate);
|
|
164
|
+
return snapshotDiagnosticJson({ observations: selected, findings: selected.length > 0 ? [] : [{
|
|
165
|
+
code: "missing-prerequisite", capability: "exposure-earning", path: `$.exposures.${input.evaluationDate}`,
|
|
166
|
+
message: "No exact exposure valuation exists for the analysis date",
|
|
167
|
+
}] });
|
|
168
|
+
}
|
|
169
|
+
const bySeries = new Map<string, CustomizationExposureObservation[]>();
|
|
170
|
+
for (const observation of dated) {
|
|
171
|
+
const seriesKey = canonicalJson([observation.id, observation.measureId, observation.startDate ?? null, observation.endDate ?? null, observation.pointDate ?? null, observation.dimensions]);
|
|
172
|
+
const values = bySeries.get(seriesKey) ?? [];
|
|
173
|
+
values.push(observation);
|
|
174
|
+
bySeries.set(seriesKey, values);
|
|
175
|
+
}
|
|
176
|
+
const selected = [...bySeries.values()].flatMap((series) => {
|
|
177
|
+
const eligible = series.filter((observation) => observation.evaluationDate! <= input.evaluationDate).sort((a, b) => a.evaluationDate!.localeCompare(b.evaluationDate!));
|
|
178
|
+
if (eligible.length === 0) return [];
|
|
179
|
+
const latest = eligible.at(-1)!.evaluationDate;
|
|
180
|
+
return eligible.filter((observation) => observation.evaluationDate === latest);
|
|
181
|
+
});
|
|
182
|
+
return snapshotDiagnosticJson({ observations: selected, findings: selected.length > 0 || dated.length === 0 ? [] : [{
|
|
183
|
+
code: "missing-prerequisite", capability: "exposure-earning", path: `$.exposures.${input.evaluationDate}`,
|
|
184
|
+
message: "No exposure valuation exists on or before the analysis date",
|
|
185
|
+
}] });
|
|
186
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { canonicalJson, fnv1a64 } from "./canonical.js";
|
|
2
|
+
import type {
|
|
3
|
+
CustomizationCapabilityFinding,
|
|
4
|
+
FinancialPipelineResult,
|
|
5
|
+
FinancialPipelineStage,
|
|
6
|
+
PreparedHistoricalObservation,
|
|
7
|
+
} from "./customizationContracts.js";
|
|
8
|
+
import { isRealIsoDate, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
|
|
9
|
+
import { applyFinancialTerms } from "./financialTerms.js";
|
|
10
|
+
|
|
11
|
+
const PIPELINE_MEASURE = "financial-pipeline-amount";
|
|
12
|
+
|
|
13
|
+
function key(observation: PreparedHistoricalObservation): string {
|
|
14
|
+
return canonicalJson([observation.sourceNamespace, observation.claimId]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function unavailable(
|
|
18
|
+
input: object,
|
|
19
|
+
inputUnit: string,
|
|
20
|
+
outputUnit: string,
|
|
21
|
+
finding: CustomizationCapabilityFinding,
|
|
22
|
+
): FinancialPipelineResult {
|
|
23
|
+
return snapshotDiagnosticJson({
|
|
24
|
+
status: "unavailable",
|
|
25
|
+
inputUnit,
|
|
26
|
+
outputUnit,
|
|
27
|
+
calculationIdentity: `fnv1a64-jcs-v1:${fnv1a64(canonicalJson(input))}`,
|
|
28
|
+
claims: [],
|
|
29
|
+
findings: [finding],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Applies caller-ordered amount transformations and shared terms with a complete audit trail. */
|
|
34
|
+
export function applyFinancialPipeline(input: {
|
|
35
|
+
readonly observations: readonly PreparedHistoricalObservation[];
|
|
36
|
+
readonly amountMeasureId: string;
|
|
37
|
+
readonly inputUnit: string;
|
|
38
|
+
readonly stages: readonly FinancialPipelineStage[];
|
|
39
|
+
readonly completeFinancialPopulation: boolean;
|
|
40
|
+
readonly reportingClaims?: readonly { readonly sourceNamespace: string; readonly claimId: string }[];
|
|
41
|
+
}): FinancialPipelineResult {
|
|
42
|
+
const identityInput = { kind: "financial-pipeline", ...input };
|
|
43
|
+
let outputUnit = input.inputUnit;
|
|
44
|
+
const working = new Map<string, { observation: PreparedHistoricalObservation; inputAmount: number; amount: number; stages: { stageId: string; stageKind: "adjustment" | "terms"; inputAmount: number; outputAmount: number }[] }>();
|
|
45
|
+
for (const observation of input.observations) {
|
|
46
|
+
const amount = observation.measures[input.amountMeasureId];
|
|
47
|
+
if (typeof amount !== "number" || !Number.isFinite(amount))
|
|
48
|
+
return unavailable(identityInput, input.inputUnit, outputUnit, { code: "missing-prerequisite", capability: "shared-financial-terms", path: `$.observations.${observation.claimId}.measures.${input.amountMeasureId}`, message: `Financial pipeline requires finite ${input.amountMeasureId}`, requiredFields: [input.amountMeasureId] });
|
|
49
|
+
if (working.has(key(observation)))
|
|
50
|
+
return unavailable(identityInput, input.inputUnit, outputUnit, { code: "incompatible-semantics", capability: "shared-financial-terms", path: "$.observations", message: "Financial pipeline requires one selected observation per claim" });
|
|
51
|
+
working.set(key(observation), { observation, inputAmount: amount, amount, stages: [] });
|
|
52
|
+
}
|
|
53
|
+
for (const stage of input.stages) {
|
|
54
|
+
if (stage.kind === "adjustment") {
|
|
55
|
+
const adjustment = stage.adjustment;
|
|
56
|
+
if ((adjustment.kind === "scale" && (!(adjustment.factor > 0) || !Number.isFinite(adjustment.factor))) ||
|
|
57
|
+
(adjustment.kind === "currency" && (!(adjustment.rate > 0) || !Number.isFinite(adjustment.rate) || !isRealIsoDate(adjustment.rateDate))))
|
|
58
|
+
return unavailable(identityInput, input.inputUnit, outputUnit, { code: "invalid-configuration", capability: "shared-financial-terms", path: `$.stages.${adjustment.id}`, message: "Scale/currency adjustments require a positive finite factor and currency requires a real rateDate" });
|
|
59
|
+
if (adjustment.kind === "currency") {
|
|
60
|
+
if (outputUnit !== adjustment.fromUnit)
|
|
61
|
+
return unavailable(identityInput, input.inputUnit, outputUnit, { code: "incompatible-semantics", capability: "shared-financial-terms", path: `$.stages.${adjustment.id}.fromUnit`, message: `Currency adjustment expected ${adjustment.fromUnit} but current unit is ${outputUnit}` });
|
|
62
|
+
outputUnit = adjustment.toUnit;
|
|
63
|
+
}
|
|
64
|
+
for (const item of working.values()) {
|
|
65
|
+
const before = item.amount;
|
|
66
|
+
if (adjustment.kind === "scale") item.amount *= adjustment.factor;
|
|
67
|
+
else if (adjustment.kind === "currency") item.amount *= adjustment.rate;
|
|
68
|
+
else {
|
|
69
|
+
const measureId = adjustment.kind === "net-recovery" ? adjustment.recoveryMeasureId : adjustment.expenseMeasureId;
|
|
70
|
+
const component = item.observation.measures[measureId];
|
|
71
|
+
if (typeof component !== "number" || !Number.isFinite(component))
|
|
72
|
+
return unavailable(identityInput, input.inputUnit, outputUnit, { code: "missing-prerequisite", capability: "shared-financial-terms", path: `$.observations.${item.observation.claimId}.measures.${measureId}`, message: `Adjustment ${adjustment.id} requires finite ${measureId}`, requiredFields: [measureId] });
|
|
73
|
+
item.amount += adjustment.kind === "include-expense" ? component : -component;
|
|
74
|
+
}
|
|
75
|
+
item.stages.push({ stageId: adjustment.id, stageKind: "adjustment", inputAmount: before, outputAmount: item.amount });
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
const observations = [...working.values()].map((item) => snapshotDiagnosticJson({ ...item.observation, measures: { ...item.observation.measures, [PIPELINE_MEASURE]: item.amount } }));
|
|
79
|
+
const terms = stage.terms.map((term) => ({ ...term, measureId: PIPELINE_MEASURE }));
|
|
80
|
+
const applied = applyFinancialTerms({ observations, terms, completeFinancialPopulation: input.completeFinancialPopulation, reportingClaims: input.reportingClaims });
|
|
81
|
+
if (applied.status === "unavailable")
|
|
82
|
+
return snapshotDiagnosticJson({ status: "unavailable", inputUnit: input.inputUnit, outputUnit, calculationIdentity: `fnv1a64-jcs-v1:${fnv1a64(canonicalJson(identityInput))}`, claims: [], findings: applied.findings });
|
|
83
|
+
for (const claim of applied.claims) {
|
|
84
|
+
const item = working.get(canonicalJson([claim.sourceNamespace, claim.claimId]))!;
|
|
85
|
+
const before = item.amount;
|
|
86
|
+
item.amount = claim.outputAmount;
|
|
87
|
+
item.stages.push({ stageId: stage.id, stageKind: "terms", inputAmount: before, outputAmount: item.amount });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const claims = [...working.values()].sort((a, b) => key(a.observation).localeCompare(key(b.observation))).map((item) => ({ sourceNamespace: item.observation.sourceNamespace, claimId: item.observation.claimId, inputAmount: item.inputAmount, outputAmount: item.amount, stages: item.stages }));
|
|
92
|
+
return snapshotDiagnosticJson({ status: "available", inputUnit: input.inputUnit, outputUnit, calculationIdentity: `fnv1a64-jcs-v1:${fnv1a64(canonicalJson(identityInput))}`, claims, findings: [] });
|
|
93
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { canonicalJson, fnv1a64 } from "./canonical.js";
|
|
2
|
+
import type {
|
|
3
|
+
CustomizationCapabilityFinding,
|
|
4
|
+
FinancialClaimAllocationResult,
|
|
5
|
+
FinancialTermApplicationResult,
|
|
6
|
+
FinancialTermDefinition,
|
|
7
|
+
FinancialTermScheduleEntry,
|
|
8
|
+
FinancialTermScheduleResolution,
|
|
9
|
+
AnalysisMeasureDefinitionV1,
|
|
10
|
+
PreparedHistoricalObservation,
|
|
11
|
+
} from "./customizationContracts.js";
|
|
12
|
+
import { isRealIsoDate, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
|
|
13
|
+
|
|
14
|
+
function claimKey(sourceNamespace: string, claimId: string): string {
|
|
15
|
+
return canonicalJson([sourceNamespace, claimId]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function finding(
|
|
19
|
+
code: CustomizationCapabilityFinding["code"],
|
|
20
|
+
message: string,
|
|
21
|
+
requiredFields?: readonly string[],
|
|
22
|
+
): CustomizationCapabilityFinding {
|
|
23
|
+
return {
|
|
24
|
+
code,
|
|
25
|
+
capability: "shared-financial-terms",
|
|
26
|
+
path: "$.financialTerms",
|
|
27
|
+
message,
|
|
28
|
+
...(requiredFields === undefined ? {} : { requiredFields }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolves the highest-priority applicable term schedule entry at a coverage date. */
|
|
33
|
+
export function resolveFinancialTermSchedule(input: {
|
|
34
|
+
readonly entries: readonly FinancialTermScheduleEntry[];
|
|
35
|
+
readonly coverageDate: string;
|
|
36
|
+
readonly policyIds: readonly string[];
|
|
37
|
+
readonly coverageIds: readonly string[];
|
|
38
|
+
}): FinancialTermScheduleResolution {
|
|
39
|
+
if (!isRealIsoDate(input.coverageDate))
|
|
40
|
+
return snapshotDiagnosticJson({
|
|
41
|
+
status: "unavailable",
|
|
42
|
+
entryId: null,
|
|
43
|
+
termIds: [],
|
|
44
|
+
finding: finding("missing-prerequisite", "A valid coverage date is required", ["coverageDate"]),
|
|
45
|
+
});
|
|
46
|
+
if (
|
|
47
|
+
input.entries.some(
|
|
48
|
+
(entry) =>
|
|
49
|
+
!isRealIsoDate(entry.effectiveStart) ||
|
|
50
|
+
!isRealIsoDate(entry.effectiveEnd) ||
|
|
51
|
+
entry.effectiveStart >= entry.effectiveEnd ||
|
|
52
|
+
!Number.isSafeInteger(entry.priority),
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
return snapshotDiagnosticJson({
|
|
56
|
+
status: "unavailable",
|
|
57
|
+
entryId: null,
|
|
58
|
+
termIds: [],
|
|
59
|
+
finding: finding("invalid-configuration", "Term schedule entries require valid intervals and safe-integer priorities"),
|
|
60
|
+
});
|
|
61
|
+
const applicable = input.entries.filter((entry) => {
|
|
62
|
+
const dateMatches = entry.effectiveStart <= input.coverageDate && input.coverageDate < entry.effectiveEnd;
|
|
63
|
+
const policyMatches = entry.policyIds === undefined || entry.policyIds.some((id) => input.policyIds.includes(id));
|
|
64
|
+
const coverageMatches = entry.coverageIds === undefined || entry.coverageIds.some((id) => input.coverageIds.includes(id));
|
|
65
|
+
return dateMatches && policyMatches && coverageMatches;
|
|
66
|
+
});
|
|
67
|
+
if (applicable.length === 0)
|
|
68
|
+
return snapshotDiagnosticJson({
|
|
69
|
+
status: "unavailable",
|
|
70
|
+
entryId: null,
|
|
71
|
+
termIds: [],
|
|
72
|
+
finding: finding("missing-prerequisite", "No financial term schedule entry applies"),
|
|
73
|
+
});
|
|
74
|
+
const priority = Math.max(...applicable.map((entry) => entry.priority));
|
|
75
|
+
const selected = applicable.filter((entry) => entry.priority === priority);
|
|
76
|
+
if (selected.length !== 1)
|
|
77
|
+
return snapshotDiagnosticJson({
|
|
78
|
+
status: "ambiguous",
|
|
79
|
+
entryId: null,
|
|
80
|
+
termIds: [],
|
|
81
|
+
finding: finding("ambiguous-attribution", `Multiple financial term schedule entries share priority ${priority}`),
|
|
82
|
+
});
|
|
83
|
+
return snapshotDiagnosticJson({
|
|
84
|
+
status: "resolved",
|
|
85
|
+
entryId: selected[0]!.id,
|
|
86
|
+
termIds: selected[0]!.termIds,
|
|
87
|
+
finding: null,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface WorkingClaim {
|
|
92
|
+
readonly observation: PreparedHistoricalObservation;
|
|
93
|
+
readonly key: string;
|
|
94
|
+
readonly inputAmount: number;
|
|
95
|
+
amount: number;
|
|
96
|
+
readonly stages: {
|
|
97
|
+
termId: string;
|
|
98
|
+
groupId: string;
|
|
99
|
+
inputAmount: number;
|
|
100
|
+
outputAmount: number;
|
|
101
|
+
removedAmount: number;
|
|
102
|
+
}[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function limitedAmount(total: number, attachment: number, limit: number | null): number {
|
|
106
|
+
const excess = Math.max(total - attachment, 0);
|
|
107
|
+
return limit === null ? excess : Math.min(excess, limit);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function unavailable(
|
|
111
|
+
observations: readonly PreparedHistoricalObservation[],
|
|
112
|
+
terms: readonly FinancialTermDefinition[],
|
|
113
|
+
issues: readonly CustomizationCapabilityFinding[],
|
|
114
|
+
): FinancialTermApplicationResult {
|
|
115
|
+
const identity = `fnv1a64-jcs-v1:${fnv1a64(canonicalJson({ kind: "financial-terms", observations, terms }))}`;
|
|
116
|
+
return snapshotDiagnosticJson({
|
|
117
|
+
status: "unavailable",
|
|
118
|
+
calculationIdentity: identity,
|
|
119
|
+
inputTotal: null,
|
|
120
|
+
outputTotal: null,
|
|
121
|
+
claims: [],
|
|
122
|
+
findings: issues,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Applies ordered shared financial terms to the complete population, then marks the reporting view. */
|
|
127
|
+
export function applyFinancialTerms(input: {
|
|
128
|
+
readonly observations: readonly PreparedHistoricalObservation[];
|
|
129
|
+
readonly terms: readonly FinancialTermDefinition[];
|
|
130
|
+
readonly completeFinancialPopulation: boolean;
|
|
131
|
+
readonly measureDefinitions?: readonly AnalysisMeasureDefinitionV1[];
|
|
132
|
+
readonly reportingClaims?: readonly { readonly sourceNamespace: string; readonly claimId: string }[];
|
|
133
|
+
}): FinancialTermApplicationResult {
|
|
134
|
+
if (!input.completeFinancialPopulation)
|
|
135
|
+
return unavailable(input.observations, input.terms, [
|
|
136
|
+
finding("incomplete-financial-population", "Shared financial terms require the complete related population"),
|
|
137
|
+
]);
|
|
138
|
+
if (input.terms.some((term) => term.kind === "policy-aggregate" && term.allocation === "chronological"))
|
|
139
|
+
return unavailable(input.observations, input.terms, [
|
|
140
|
+
finding("unsupported-operation", "Chronological aggregate allocation requires ordered transaction input", ["ordered transactions"]),
|
|
141
|
+
]);
|
|
142
|
+
const sourceMeasure = (id: string): string | undefined => {
|
|
143
|
+
const definition = input.measureDefinitions?.find((measure) => measure.id === id);
|
|
144
|
+
if (definition === undefined) return input.measureDefinitions === undefined ? id : undefined;
|
|
145
|
+
return definition.statistic.kind === "sum" ? definition.statistic.measureId : undefined;
|
|
146
|
+
};
|
|
147
|
+
const measureIds = [...new Set(input.terms.map((term) => sourceMeasure(term.measureId)))];
|
|
148
|
+
if (measureIds.includes(undefined))
|
|
149
|
+
return unavailable(input.observations, input.terms, [
|
|
150
|
+
finding("incompatible-semantics", "Financial terms require a referenced sum measure"),
|
|
151
|
+
]);
|
|
152
|
+
if (measureIds.length > 1)
|
|
153
|
+
return unavailable(input.observations, input.terms, [
|
|
154
|
+
finding("incompatible-semantics", "One ordered financial term application must use a single amount measure"),
|
|
155
|
+
]);
|
|
156
|
+
const measureId = measureIds[0];
|
|
157
|
+
if (measureId === undefined)
|
|
158
|
+
return unavailable(input.observations, input.terms, [
|
|
159
|
+
finding("invalid-configuration", "At least one financial term is required"),
|
|
160
|
+
]);
|
|
161
|
+
const seen = new Set<string>();
|
|
162
|
+
const working: WorkingClaim[] = [];
|
|
163
|
+
for (const observation of input.observations) {
|
|
164
|
+
const key = claimKey(observation.sourceNamespace, observation.claimId);
|
|
165
|
+
if (seen.has(key))
|
|
166
|
+
return unavailable(input.observations, input.terms, [
|
|
167
|
+
finding("incompatible-semantics", "Financial input must contain one selected observation per claim"),
|
|
168
|
+
]);
|
|
169
|
+
seen.add(key);
|
|
170
|
+
const amount = observation.measures[measureId];
|
|
171
|
+
if (typeof amount !== "number" || !Number.isFinite(amount))
|
|
172
|
+
return unavailable(input.observations, input.terms, [
|
|
173
|
+
finding("missing-prerequisite", `Claim ${observation.claimId} lacks finite measure ${measureId}`, [measureId]),
|
|
174
|
+
]);
|
|
175
|
+
if (amount < 0)
|
|
176
|
+
return unavailable(input.observations, input.terms, [
|
|
177
|
+
finding("unsupported-operation", "Negative amounts and recoveries require a separately declared financial convention"),
|
|
178
|
+
]);
|
|
179
|
+
working.push({ observation, key, inputAmount: amount, amount, stages: [] });
|
|
180
|
+
}
|
|
181
|
+
working.sort((left, right) => left.key.localeCompare(right.key));
|
|
182
|
+
|
|
183
|
+
for (const term of input.terms) {
|
|
184
|
+
const groups = new Map<string, WorkingClaim[]>();
|
|
185
|
+
for (const claim of working) {
|
|
186
|
+
let groupId: string | undefined;
|
|
187
|
+
if (term.kind === "claim-layer") groupId = claim.key;
|
|
188
|
+
else if (term.kind === "occurrence-layer") {
|
|
189
|
+
const occurrenceId = claim.observation.relationships.occurrenceId;
|
|
190
|
+
if (occurrenceId !== undefined) groupId = canonicalJson([claim.observation.sourceNamespace, occurrenceId]);
|
|
191
|
+
} else {
|
|
192
|
+
const policyIds = claim.observation.relationships.policyIds;
|
|
193
|
+
if (policyIds?.length === 1) groupId = canonicalJson([claim.observation.sourceNamespace, policyIds[0]]);
|
|
194
|
+
}
|
|
195
|
+
if (groupId === undefined)
|
|
196
|
+
return unavailable(input.observations, input.terms, [
|
|
197
|
+
finding(
|
|
198
|
+
"missing-prerequisite",
|
|
199
|
+
`${term.kind} requires one unambiguous grouping identifier for claim ${claim.observation.claimId}`,
|
|
200
|
+
[term.kind === "occurrence-layer" ? "occurrenceId" : "policyId"],
|
|
201
|
+
),
|
|
202
|
+
]);
|
|
203
|
+
const group = groups.get(groupId);
|
|
204
|
+
if (group === undefined) groups.set(groupId, [claim]);
|
|
205
|
+
else group.push(claim);
|
|
206
|
+
}
|
|
207
|
+
for (const [groupId, group] of groups) {
|
|
208
|
+
const total = group.reduce((sum, claim) => sum + claim.amount, 0);
|
|
209
|
+
const output =
|
|
210
|
+
term.kind === "policy-aggregate"
|
|
211
|
+
? limitedAmount(total, term.deductible, term.limit)
|
|
212
|
+
: limitedAmount(total, term.attachment, term.limit);
|
|
213
|
+
let allocated = 0;
|
|
214
|
+
group.forEach((claim, index) => {
|
|
215
|
+
const before = claim.amount;
|
|
216
|
+
const after =
|
|
217
|
+
index === group.length - 1
|
|
218
|
+
? output - allocated
|
|
219
|
+
: total === 0
|
|
220
|
+
? 0
|
|
221
|
+
: output * (before / total);
|
|
222
|
+
allocated += after;
|
|
223
|
+
claim.amount = Object.is(after, -0) ? 0 : after;
|
|
224
|
+
claim.stages.push({
|
|
225
|
+
termId: term.id,
|
|
226
|
+
groupId,
|
|
227
|
+
inputAmount: before,
|
|
228
|
+
outputAmount: claim.amount,
|
|
229
|
+
removedAmount: before - claim.amount,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const reported = new Set(
|
|
236
|
+
input.reportingClaims?.map((claim) => claimKey(claim.sourceNamespace, claim.claimId)) ?? working.map((claim) => claim.key),
|
|
237
|
+
);
|
|
238
|
+
const claims: FinancialClaimAllocationResult[] = working
|
|
239
|
+
.sort((left, right) => left.key.localeCompare(right.key))
|
|
240
|
+
.map((claim) => ({
|
|
241
|
+
sourceNamespace: claim.observation.sourceNamespace,
|
|
242
|
+
claimId: claim.observation.claimId,
|
|
243
|
+
reported: reported.has(claim.key),
|
|
244
|
+
inputAmount: claim.inputAmount,
|
|
245
|
+
outputAmount: claim.amount,
|
|
246
|
+
stages: claim.stages,
|
|
247
|
+
}));
|
|
248
|
+
const identityValue = {
|
|
249
|
+
kind: "financial-terms",
|
|
250
|
+
population: [...input.observations].sort((left, right) =>
|
|
251
|
+
claimKey(left.sourceNamespace, left.claimId).localeCompare(claimKey(right.sourceNamespace, right.claimId)),
|
|
252
|
+
),
|
|
253
|
+
terms: input.terms,
|
|
254
|
+
};
|
|
255
|
+
return snapshotDiagnosticJson({
|
|
256
|
+
status: "available",
|
|
257
|
+
calculationIdentity: `fnv1a64-jcs-v1:${fnv1a64(canonicalJson(identityValue))}`,
|
|
258
|
+
inputTotal: claims.reduce((total, claim) => total + claim.inputAmount, 0),
|
|
259
|
+
outputTotal: claims.reduce((total, claim) => total + claim.outputAmount, 0),
|
|
260
|
+
claims,
|
|
261
|
+
findings: [],
|
|
262
|
+
});
|
|
263
|
+
}
|
package/src/fnvAccumulator.ts
CHANGED
|
@@ -4,14 +4,20 @@ export function createFnvAccumulator() {
|
|
|
4
4
|
let low = 0x84222325;
|
|
5
5
|
return {
|
|
6
6
|
update(bytes: Uint8Array) {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// Keep the hot byte loop's words local rather than writing captured
|
|
8
|
+
// accumulator state on every byte. Chunk boundaries do not affect FNV.
|
|
9
|
+
let nextHigh = high;
|
|
10
|
+
let nextLow = low;
|
|
11
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
12
|
+
nextLow = (nextLow ^ bytes[index]!) >>> 0;
|
|
9
13
|
// FNV_PRIME = 2^40 + 435. Every intermediate stays below 2^42,
|
|
10
14
|
// preserving the exact modular multiplication of the BigInt form.
|
|
11
|
-
const product =
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
const product = nextLow * 0x1b3;
|
|
16
|
+
nextHigh = (nextHigh * 0x1b3 + Math.floor(product / 0x100000000) + (nextLow << 8)) >>> 0;
|
|
17
|
+
nextLow = product >>> 0;
|
|
14
18
|
}
|
|
19
|
+
high = nextHigh;
|
|
20
|
+
low = nextLow;
|
|
15
21
|
},
|
|
16
22
|
digest() {
|
|
17
23
|
return high.toString(16).padStart(8, "0") + low.toString(16).padStart(8, "0");
|