@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,320 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnalysisMeasureDefinitionV1,
|
|
3
|
+
AnalysisObservationSelection,
|
|
4
|
+
AnalysisStatisticResult,
|
|
5
|
+
CustomizationCapabilityFinding,
|
|
6
|
+
HistoricalRelationships,
|
|
7
|
+
PreparedHistoricalObservation,
|
|
8
|
+
} from "./customizationContracts.js";
|
|
9
|
+
import { isRealIsoDate, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
|
|
10
|
+
import { daysBetweenIsoDates } from "./policyPeriods.js";
|
|
11
|
+
import { kaplanMeierQuantiles } from "./ilf.js";
|
|
12
|
+
|
|
13
|
+
function claimKey(observation: PreparedHistoricalObservation): string {
|
|
14
|
+
return `${observation.sourceNamespace}\u0000${observation.claimId}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function entityIds(
|
|
18
|
+
entity: Extract<AnalysisMeasureDefinitionV1["statistic"], { kind: "distinct-count" }>["entity"],
|
|
19
|
+
observation: PreparedHistoricalObservation,
|
|
20
|
+
): readonly string[] {
|
|
21
|
+
if (entity === "claim") return [claimKey(observation)];
|
|
22
|
+
const field: keyof HistoricalRelationships =
|
|
23
|
+
entity === "claimant"
|
|
24
|
+
? "claimantIds"
|
|
25
|
+
: entity === "occurrence"
|
|
26
|
+
? "occurrenceId"
|
|
27
|
+
: entity === "policy"
|
|
28
|
+
? "policyIds"
|
|
29
|
+
: "coverageIds";
|
|
30
|
+
const raw = observation.relationships[field];
|
|
31
|
+
const ids = raw === undefined ? [] : Array.isArray(raw) ? raw : [raw];
|
|
32
|
+
return ids.map((id) => `${observation.sourceNamespace}\u0000${id}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function selectObservations(
|
|
36
|
+
observations: readonly PreparedHistoricalObservation[],
|
|
37
|
+
selection: AnalysisObservationSelection,
|
|
38
|
+
): readonly PreparedHistoricalObservation[] {
|
|
39
|
+
if (selection.kind === "all-observations") return observations;
|
|
40
|
+
if (selection.observationSelection === "exact")
|
|
41
|
+
return observations.filter((observation) => observation.evaluationDate === selection.evaluationDate);
|
|
42
|
+
const latest = new Map<string, PreparedHistoricalObservation>();
|
|
43
|
+
for (const observation of observations) {
|
|
44
|
+
if (observation.evaluationDate > selection.evaluationDate) continue;
|
|
45
|
+
const key = claimKey(observation);
|
|
46
|
+
const prior = latest.get(key);
|
|
47
|
+
if (prior === undefined || prior.evaluationDate < observation.evaluationDate)
|
|
48
|
+
latest.set(key, observation);
|
|
49
|
+
}
|
|
50
|
+
return [...latest.values()].sort((left, right) => claimKey(left).localeCompare(claimKey(right)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function finding(
|
|
54
|
+
message: string,
|
|
55
|
+
code: CustomizationCapabilityFinding["code"] = "missing-prerequisite",
|
|
56
|
+
): CustomizationCapabilityFinding {
|
|
57
|
+
return {
|
|
58
|
+
code,
|
|
59
|
+
capability: "descriptive-statistics",
|
|
60
|
+
path: "$.measure.statistic",
|
|
61
|
+
message,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unavailable(
|
|
66
|
+
measure: AnalysisMeasureDefinitionV1,
|
|
67
|
+
excludedObservations: number,
|
|
68
|
+
findings: readonly CustomizationCapabilityFinding[],
|
|
69
|
+
): AnalysisStatisticResult {
|
|
70
|
+
return snapshotDiagnosticJson({
|
|
71
|
+
measureId: measure.id,
|
|
72
|
+
unit: measure.unit,
|
|
73
|
+
value: null,
|
|
74
|
+
status: "unavailable",
|
|
75
|
+
contributingObservations: 0,
|
|
76
|
+
excludedObservations,
|
|
77
|
+
exact: true,
|
|
78
|
+
numerator: null,
|
|
79
|
+
denominator: null,
|
|
80
|
+
findings,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sourceValue(
|
|
85
|
+
observation: PreparedHistoricalObservation,
|
|
86
|
+
measureId: string,
|
|
87
|
+
): number | null | undefined {
|
|
88
|
+
const value = observation.measures[measureId];
|
|
89
|
+
return value === undefined || value === null || !Number.isFinite(value) ? value : Object.is(value, -0) ? 0 : value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hfType7(values: readonly number[], probability: number): number {
|
|
93
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
94
|
+
if (sorted.length === 1) return sorted[0]!;
|
|
95
|
+
const index = (sorted.length - 1) * probability;
|
|
96
|
+
const lower = Math.floor(index);
|
|
97
|
+
const fraction = index - lower;
|
|
98
|
+
return sorted[lower]! + fraction * (sorted[Math.min(lower + 1, sorted.length - 1)]! - sorted[lower]!);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function durationValue(
|
|
102
|
+
observation: PreparedHistoricalObservation,
|
|
103
|
+
statistic: Extract<AnalysisMeasureDefinitionV1["statistic"], { kind: "duration" }>,
|
|
104
|
+
): number | null {
|
|
105
|
+
const start = statistic.start === "accident-date" ? observation.accidentDate : observation.reportDate;
|
|
106
|
+
const closed = observation.dimensions.closedDate;
|
|
107
|
+
const end =
|
|
108
|
+
statistic.end === "report-date"
|
|
109
|
+
? observation.reportDate
|
|
110
|
+
: statistic.end === "evaluation-date"
|
|
111
|
+
? observation.evaluationDate
|
|
112
|
+
: typeof closed === "string" && isRealIsoDate(closed)
|
|
113
|
+
? closed
|
|
114
|
+
: null;
|
|
115
|
+
if (start === null || end === null) return null;
|
|
116
|
+
const days = daysBetweenIsoDates(start, end);
|
|
117
|
+
return days < 0 ? null : days;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Calculates one exact statistic over an explicitly selected observation population. */
|
|
121
|
+
export function calculateAnalysisStatistic(input: {
|
|
122
|
+
readonly measure: AnalysisMeasureDefinitionV1;
|
|
123
|
+
readonly observations: readonly PreparedHistoricalObservation[];
|
|
124
|
+
readonly selection: AnalysisObservationSelection;
|
|
125
|
+
}): AnalysisStatisticResult {
|
|
126
|
+
const { measure, selection } = input;
|
|
127
|
+
if (measure.developmentSemantics === "cumulative" && selection.kind === "all-observations")
|
|
128
|
+
return unavailable(
|
|
129
|
+
measure,
|
|
130
|
+
input.observations.length,
|
|
131
|
+
[finding("Cumulative measures require an explicit evaluation-date selection", "incompatible-semantics")],
|
|
132
|
+
);
|
|
133
|
+
const observations = selectObservations(input.observations, selection);
|
|
134
|
+
const statistic = measure.statistic;
|
|
135
|
+
|
|
136
|
+
if (statistic.kind === "ratio") {
|
|
137
|
+
let numerator = 0;
|
|
138
|
+
let denominator = 0;
|
|
139
|
+
let included = 0;
|
|
140
|
+
for (const observation of observations) {
|
|
141
|
+
const numeratorValue = sourceValue(observation, statistic.numeratorMeasureId);
|
|
142
|
+
const denominatorValue = sourceValue(observation, statistic.denominatorMeasureId);
|
|
143
|
+
if (typeof numeratorValue !== "number" || typeof denominatorValue !== "number") continue;
|
|
144
|
+
numerator += numeratorValue;
|
|
145
|
+
denominator += denominatorValue;
|
|
146
|
+
included += 1;
|
|
147
|
+
}
|
|
148
|
+
const validDenominator = statistic.denominatorRule === "positive" ? denominator > 0 : denominator !== 0;
|
|
149
|
+
if (included === 0 || !validDenominator)
|
|
150
|
+
return snapshotDiagnosticJson({
|
|
151
|
+
measureId: measure.id,
|
|
152
|
+
unit: measure.unit,
|
|
153
|
+
value: null,
|
|
154
|
+
status: "unavailable",
|
|
155
|
+
contributingObservations: included,
|
|
156
|
+
excludedObservations: input.observations.length - included,
|
|
157
|
+
exact: true,
|
|
158
|
+
numerator: included === 0 ? null : numerator,
|
|
159
|
+
denominator: included === 0 ? null : denominator,
|
|
160
|
+
findings: [finding("Ratio requires paired values and a denominator allowed by its declared rule")],
|
|
161
|
+
});
|
|
162
|
+
return snapshotDiagnosticJson({
|
|
163
|
+
measureId: measure.id,
|
|
164
|
+
unit: measure.unit,
|
|
165
|
+
value: (numerator / denominator) * statistic.scale,
|
|
166
|
+
status: "available",
|
|
167
|
+
contributingObservations: included,
|
|
168
|
+
excludedObservations: input.observations.length - included,
|
|
169
|
+
exact: true,
|
|
170
|
+
numerator,
|
|
171
|
+
denominator,
|
|
172
|
+
findings: [],
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (statistic.kind === "distinct-count") {
|
|
177
|
+
const ids = new Set<string>();
|
|
178
|
+
let missing = 0;
|
|
179
|
+
for (const observation of observations) {
|
|
180
|
+
const related = entityIds(statistic.entity, observation);
|
|
181
|
+
if (related.length === 0) missing += 1;
|
|
182
|
+
related.forEach((id) => ids.add(id));
|
|
183
|
+
}
|
|
184
|
+
return snapshotDiagnosticJson({
|
|
185
|
+
measureId: measure.id,
|
|
186
|
+
unit: measure.unit,
|
|
187
|
+
value: ids.size,
|
|
188
|
+
status: "available",
|
|
189
|
+
contributingObservations: observations.length - missing,
|
|
190
|
+
excludedObservations: input.observations.length - observations.length + missing,
|
|
191
|
+
exact: true,
|
|
192
|
+
numerator: null,
|
|
193
|
+
denominator: null,
|
|
194
|
+
findings: [],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (statistic.kind === "duration") {
|
|
199
|
+
const values = observations
|
|
200
|
+
.filter((observation) => statistic.population !== "closed-only" || observation.status === "closed")
|
|
201
|
+
.map((observation) => durationValue(observation, statistic))
|
|
202
|
+
.filter((value): value is number => value !== null);
|
|
203
|
+
if (values.length === 0)
|
|
204
|
+
return unavailable(measure, input.observations.length, [finding("No observations have the required duration endpoints")]);
|
|
205
|
+
const value = values.reduce((total, item) => total + item, 0) / values.length;
|
|
206
|
+
return snapshotDiagnosticJson({
|
|
207
|
+
measureId: measure.id,
|
|
208
|
+
unit: measure.unit,
|
|
209
|
+
value,
|
|
210
|
+
status: "available",
|
|
211
|
+
contributingObservations: values.length,
|
|
212
|
+
excludedObservations: input.observations.length - values.length,
|
|
213
|
+
exact: true,
|
|
214
|
+
numerator: null,
|
|
215
|
+
denominator: null,
|
|
216
|
+
findings: [],
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (statistic.kind === "survival-quantile") {
|
|
221
|
+
if (statistic.probability < 0 || statistic.probability > 1)
|
|
222
|
+
return unavailable(measure, input.observations.length, [finding("Survival probability must be between zero and one", "invalid-configuration")]);
|
|
223
|
+
const exact: number[] = [];
|
|
224
|
+
const censored: number[] = [];
|
|
225
|
+
for (const observation of observations) {
|
|
226
|
+
const start = statistic.start === "accident-date" ? observation.accidentDate : observation.reportDate;
|
|
227
|
+
const suppliedClosedDate = observation.dimensions.closedDate;
|
|
228
|
+
const isObservedEvent = observation.status === "closed" && typeof suppliedClosedDate === "string" && isRealIsoDate(suppliedClosedDate);
|
|
229
|
+
const end = isObservedEvent ? suppliedClosedDate : observation.evaluationDate;
|
|
230
|
+
if (start === null || !isRealIsoDate(end)) continue;
|
|
231
|
+
const duration = daysBetweenIsoDates(start, end);
|
|
232
|
+
if (duration < 0) continue;
|
|
233
|
+
(isObservedEvent ? exact : censored).push(duration);
|
|
234
|
+
}
|
|
235
|
+
if (exact.length === 0)
|
|
236
|
+
return unavailable(measure, input.observations.length, [finding("Kaplan-Meier duration quantiles require at least one observed closure event")]);
|
|
237
|
+
const value = kaplanMeierQuantiles(exact, censored, [statistic.probability])[0] ?? null;
|
|
238
|
+
if (value === null)
|
|
239
|
+
return snapshotDiagnosticJson({
|
|
240
|
+
measureId: measure.id,
|
|
241
|
+
unit: measure.unit,
|
|
242
|
+
value: null,
|
|
243
|
+
status: "unavailable",
|
|
244
|
+
contributingObservations: exact.length + censored.length,
|
|
245
|
+
excludedObservations: input.observations.length - exact.length - censored.length,
|
|
246
|
+
exact: true,
|
|
247
|
+
numerator: null,
|
|
248
|
+
denominator: null,
|
|
249
|
+
findings: [finding("The observed survival curve does not reach the requested quantile because of right censoring")],
|
|
250
|
+
});
|
|
251
|
+
return snapshotDiagnosticJson({
|
|
252
|
+
measureId: measure.id,
|
|
253
|
+
unit: measure.unit,
|
|
254
|
+
value,
|
|
255
|
+
status: "available",
|
|
256
|
+
contributingObservations: exact.length + censored.length,
|
|
257
|
+
excludedObservations: input.observations.length - exact.length - censored.length,
|
|
258
|
+
exact: true,
|
|
259
|
+
numerator: null,
|
|
260
|
+
denominator: null,
|
|
261
|
+
findings: [],
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (statistic.kind === "weighted-mean") {
|
|
266
|
+
let weighted = 0;
|
|
267
|
+
let weights = 0;
|
|
268
|
+
let included = 0;
|
|
269
|
+
for (const observation of observations) {
|
|
270
|
+
const value = sourceValue(observation, statistic.measureId);
|
|
271
|
+
const weight = sourceValue(observation, statistic.weightMeasureId);
|
|
272
|
+
if (typeof value !== "number" || typeof weight !== "number") continue;
|
|
273
|
+
if (weight < 0)
|
|
274
|
+
return unavailable(measure, input.observations.length, [finding("Negative weights are unsupported", "incompatible-semantics")]);
|
|
275
|
+
if (weight === 0) continue;
|
|
276
|
+
weighted += value * weight;
|
|
277
|
+
weights += weight;
|
|
278
|
+
included += 1;
|
|
279
|
+
}
|
|
280
|
+
if (included === 0 || weights === 0)
|
|
281
|
+
return unavailable(measure, input.observations.length, [finding("Weighted mean requires at least one positive finite weight")]);
|
|
282
|
+
return snapshotDiagnosticJson({
|
|
283
|
+
measureId: measure.id,
|
|
284
|
+
unit: measure.unit,
|
|
285
|
+
value: weighted / weights,
|
|
286
|
+
status: "available",
|
|
287
|
+
contributingObservations: included,
|
|
288
|
+
excludedObservations: input.observations.length - included,
|
|
289
|
+
exact: true,
|
|
290
|
+
numerator: null,
|
|
291
|
+
denominator: null,
|
|
292
|
+
findings: [],
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const values = observations
|
|
297
|
+
.map((observation) => sourceValue(observation, statistic.measureId))
|
|
298
|
+
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
299
|
+
if (values.length === 0)
|
|
300
|
+
return unavailable(measure, input.observations.length, [finding("No finite observed values are available")]);
|
|
301
|
+
let value: number;
|
|
302
|
+
if (statistic.kind === "sum") value = values.reduce((total, item) => total + item, 0);
|
|
303
|
+
else if (statistic.kind === "quantile") {
|
|
304
|
+
if (statistic.probability < 0 || statistic.probability > 1)
|
|
305
|
+
return unavailable(measure, input.observations.length, [finding("Quantile probability must be between zero and one", "invalid-configuration")]);
|
|
306
|
+
value = hfType7(values, statistic.probability);
|
|
307
|
+
} else value = statistic.kind === "minimum" ? Math.min(...values) : Math.max(...values);
|
|
308
|
+
return snapshotDiagnosticJson({
|
|
309
|
+
measureId: measure.id,
|
|
310
|
+
unit: measure.unit,
|
|
311
|
+
value: Object.is(value, -0) ? 0 : value,
|
|
312
|
+
status: "available",
|
|
313
|
+
contributingObservations: values.length,
|
|
314
|
+
excludedObservations: input.observations.length - values.length,
|
|
315
|
+
exact: true,
|
|
316
|
+
numerator: null,
|
|
317
|
+
denominator: null,
|
|
318
|
+
findings: [],
|
|
319
|
+
});
|
|
320
|
+
}
|
|
@@ -99,6 +99,22 @@ export function finalizeDiagnosticContributions(
|
|
|
99
99
|
missingPolicy: DiagnosticMissingPolicy,
|
|
100
100
|
blockers: readonly DiagnosticStructuralBlocker[] = [],
|
|
101
101
|
): DiagnosticMeasureStats {
|
|
102
|
+
// Aggregate loss snapshots commonly have one leaf per measure/cell. Sorting
|
|
103
|
+
// and materializing status partitions cannot change a singleton's order.
|
|
104
|
+
if (contributions.length === 1) {
|
|
105
|
+
const item = contributions[0]!;
|
|
106
|
+
const observed = item.status === "observed" ? 1 : 0;
|
|
107
|
+
const imputedZero = item.status === "imputed-zero" ? 1 : 0;
|
|
108
|
+
const missing = item.status === "missing" ? 1 : imputedZero;
|
|
109
|
+
const nonFinite = item.status === "non-finite" ? 1 : 0;
|
|
110
|
+
const finiteSum = item.status === "observed" ? neumaier([item.value]) : 0;
|
|
111
|
+
const sum = nonFinite > 0 || blockers.length > 0 ? null : finiteSum;
|
|
112
|
+
return Object.freeze({
|
|
113
|
+
value: sum === null || (missingPolicy === "unknown" && missing > 0) ? null : sum,
|
|
114
|
+
sum, observed, missing, nonFinite, imputedZero,
|
|
115
|
+
deduplicated: 0 + item.deduplicated, structural: blockers.length,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
102
118
|
const ordered = [...contributions].sort(compareDiagnosticContributions);
|
|
103
119
|
const observed = ordered.filter((item) => item.status === "observed");
|
|
104
120
|
const imputed = ordered.filter((item) => item.status === "imputed-zero");
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { DiagnosticDeepReadonly } from "./diagnosticDefinitions.js";
|
|
2
|
+
|
|
3
|
+
// Private knowledge of graphs traversed by this freezer. Object.isFrozen alone
|
|
4
|
+
// is not evidence that caller-supplied children are immutable. Weak membership
|
|
5
|
+
// does not keep released diagnostic datasets alive.
|
|
6
|
+
let completedGraphs = new WeakSet<object>();
|
|
7
|
+
let completedCount = 0;
|
|
8
|
+
const MAX_COMPLETED_MEMBERS = 100_000;
|
|
9
|
+
|
|
10
|
+
/** Freeze owned plain-data snapshots, visiting shared subgraphs only once. */
|
|
11
|
+
export function freezeDiagnosticGraph<T>(value: T): DiagnosticDeepReadonly<T> {
|
|
12
|
+
// This walk already retains its nodes until success; a strong local set avoids
|
|
13
|
+
// a second large weak table and is released when the invocation returns.
|
|
14
|
+
const seen = new Set<object>();
|
|
15
|
+
const visit = (node: unknown): void => {
|
|
16
|
+
if (node === null || typeof node !== "object" || seen.has(node) || completedGraphs.has(node)) return;
|
|
17
|
+
seen.add(node);
|
|
18
|
+
for (const child of Object.values(node as Record<string, unknown>)) visit(child);
|
|
19
|
+
Object.freeze(node);
|
|
20
|
+
};
|
|
21
|
+
visit(value);
|
|
22
|
+
// Mark only after the whole walk succeeds: a cyclic child must not become
|
|
23
|
+
// trusted while pointing at a parent whose freeze failed.
|
|
24
|
+
for (const node of seen) {
|
|
25
|
+
// This is a traversal shortcut, never an authentication receipt. Forgetting
|
|
26
|
+
// old membership is safe: a later visit traverses and freezes that graph
|
|
27
|
+
// again. Bound the weak-table bookkeeping even when millions of source
|
|
28
|
+
// objects are still strongly retained by a large diagnostic dataset.
|
|
29
|
+
if (completedCount >= MAX_COMPLETED_MEMBERS) {
|
|
30
|
+
completedGraphs = new WeakSet<object>();
|
|
31
|
+
completedCount = 0;
|
|
32
|
+
}
|
|
33
|
+
completedGraphs.add(node);
|
|
34
|
+
completedCount++;
|
|
35
|
+
}
|
|
36
|
+
return value as DiagnosticDeepReadonly<T>;
|
|
37
|
+
}
|
|
@@ -36,6 +36,7 @@ interface Fields {
|
|
|
36
36
|
readonly keys: readonly string[];
|
|
37
37
|
readonly values: readonly unknown[];
|
|
38
38
|
readonly documents: boolean;
|
|
39
|
+
readonly documentKeys?: ReadonlySet<string>;
|
|
39
40
|
}
|
|
40
41
|
const nodes = new WeakMap<DiagnosticIdentityDocument, Node>();
|
|
41
42
|
const CHUNK_UNITS = 16_384;
|
|
@@ -84,6 +85,40 @@ export function createDiagnosticIdentityObject(
|
|
|
84
85
|
return descriptor({ kind: "object", fields: { keys, values, documents: true } });
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/** @internal Compose raw field snapshots with lazy document children without
|
|
89
|
+
* allocating a descriptor for every scalar. This grants no identity authority;
|
|
90
|
+
* raw children retain the normal live-value validation during iteration. */
|
|
91
|
+
export function createDiagnosticIdentityMixedObject(
|
|
92
|
+
raw: Readonly<Record<string, unknown>>,
|
|
93
|
+
documents: Readonly<Record<string, DiagnosticIdentityDocument>>,
|
|
94
|
+
): DiagnosticIdentityDocument {
|
|
95
|
+
if (!isDiagnosticPlainRecord(raw) || !isDiagnosticPlainRecord(documents))
|
|
96
|
+
fail("$", "Identity fields must be plain records", "invalid-type");
|
|
97
|
+
const rawSnapshot = rawFields(raw, "$"), documentSnapshot = rawFields(documents, "$");
|
|
98
|
+
const valuesByKey = new Map(rawSnapshot.keys.map((key, index) => [key, rawSnapshot.values[index]]));
|
|
99
|
+
for (let index = 0; index < documentSnapshot.keys.length; index++) {
|
|
100
|
+
const key = documentSnapshot.keys[index]!;
|
|
101
|
+
if (valuesByKey.has(key)) fail(childPath("$", key), "Duplicate identity field", "invalid-type");
|
|
102
|
+
const value = documentSnapshot.values[index] as DiagnosticIdentityDocument;
|
|
103
|
+
readDocument(value, childPath("$", key));
|
|
104
|
+
valuesByKey.set(key, value);
|
|
105
|
+
}
|
|
106
|
+
const keys = [...valuesByKey.keys()].sort();
|
|
107
|
+
return descriptor({
|
|
108
|
+
kind: "object",
|
|
109
|
+
fields: {
|
|
110
|
+
keys,
|
|
111
|
+
values: keys.map((key) => valuesByKey.get(key)),
|
|
112
|
+
documents: false,
|
|
113
|
+
documentKeys: new Set(documentSnapshot.keys),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isDocumentField(fields: Fields, index: number): boolean {
|
|
119
|
+
return fields.documents || !!fields.documentKeys?.has(fields.keys[index]!);
|
|
120
|
+
}
|
|
121
|
+
|
|
87
122
|
/**
|
|
88
123
|
* A structural lazy sequence. The callback grants NO review/run authority and
|
|
89
124
|
* is invoked only as iteration reaches an index. It must remain repeatable for
|
|
@@ -110,7 +145,7 @@ function childPath(path: string, key: string): string {
|
|
|
110
145
|
: `${path}[${JSON.stringify(key)}]`;
|
|
111
146
|
}
|
|
112
147
|
function scalar(fields: Fields, index: number): unknown {
|
|
113
|
-
if (!fields
|
|
148
|
+
if (!isDocumentField(fields, index)) return fields.values[index];
|
|
114
149
|
const node = readDocument(fields.values[index] as DiagnosticIdentityDocument, "$");
|
|
115
150
|
// Non-scalar composition has an object value, and cannot be a source token.
|
|
116
151
|
return node.kind === "value" ? node.value : {};
|
|
@@ -373,7 +408,7 @@ export function* iterateDiagnosticIdentityJson(
|
|
|
373
408
|
depth: depth + 1,
|
|
374
409
|
};
|
|
375
410
|
stack.push(
|
|
376
|
-
fields
|
|
411
|
+
isDocumentField(fields, index)
|
|
377
412
|
? { kind: "document", document: child as DiagnosticIdentityDocument, ...location }
|
|
378
413
|
: { kind: "value", value: child, ...location },
|
|
379
414
|
);
|