@actuarial-ts/data 0.6.0 → 0.7.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 +3 -3
- package/dist/compactDiagnosticJson.d.ts +24 -0
- package/dist/compactDiagnosticJson.d.ts.map +1 -0
- package/dist/compactDiagnosticJson.js +200 -0
- package/dist/compactDiagnosticJson.js.map +1 -0
- package/dist/diagnosticInput.d.ts +55 -4
- package/dist/diagnosticInput.d.ts.map +1 -1
- package/dist/diagnosticInput.js +499 -35
- package/dist/diagnosticInput.js.map +1 -1
- package/dist/diagnosticPreparedReview.d.ts +72 -6
- package/dist/diagnosticPreparedReview.d.ts.map +1 -1
- package/dist/diagnosticPreparedReview.js +749 -38
- package/dist/diagnosticPreparedReview.js.map +1 -1
- package/dist/exposure.d.ts.map +1 -1
- package/dist/exposure.js +25 -9
- package/dist/exposure.js.map +1 -1
- package/dist/lossRun.d.ts.map +1 -1
- package/dist/lossRun.js +31 -8
- package/dist/lossRun.js.map +1 -1
- package/dist/review.d.ts +1 -2
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +33 -4
- package/dist/review.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -3
- package/src/compactDiagnosticJson.ts +216 -0
- package/src/diagnosticInput.ts +746 -51
- package/src/diagnosticPreparedReview.ts +1189 -37
- package/src/exposure.ts +61 -16
- package/src/lossRun.ts +55 -13
- package/src/review.ts +177 -40
- package/src/version.ts +1 -1
|
@@ -1,58 +1,769 @@
|
|
|
1
|
-
import { canonicalJson, evaluateDiagnosticReviewRules, fnv1a64 } from "@actuarial-ts/core";
|
|
2
|
-
import {
|
|
1
|
+
import { DiagnosticValidationError, diagnosticJsonPreflight, isDiagnosticToken, assertPreparedDiagnosticData, assertCompactPreparedDiagnosticData, evaluateDiagnosticReviewRulesCompact, getDiagnosticReviewEvaluation, getDiagnosticReviewEvaluationSummary, getCompactDiagnosticReviewEvaluationsIdentityDocument, getCompactPreparedDiagnosticDataFingerprint, createDiagnosticIdentityArray, createDiagnosticIdentityObject, createDiagnosticIdentityValue, fingerprintDiagnosticIdentity, canonicalJson, evaluateDiagnosticReviewRules, fnv1a64, compareDiagnosticSourceLocations, compareDiagnosticIdentityValues, normalizeDiagnosticSourceLocations, projectDiagnosticIdentity, } from "@actuarial-ts/core";
|
|
2
|
+
import { CompactDiagnosticJson } from "./compactDiagnosticJson.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { createNotEvaluatedDataCheck, createStructuredDataCheck, summarizeDataChecks, } from "./review.js";
|
|
3
5
|
const fixed = [
|
|
4
6
|
["diagnostic/structural/loss-identity", "Loss identities are unique", "fail"],
|
|
5
|
-
[
|
|
7
|
+
[
|
|
8
|
+
"diagnostic/structural/exposure-identity",
|
|
9
|
+
"Exposure identities are coherent",
|
|
10
|
+
"fail",
|
|
11
|
+
],
|
|
6
12
|
["diagnostic/structural/period-validity", "Periods are valid", "fail"],
|
|
7
|
-
[
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
[
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
[
|
|
14
|
+
"diagnostic/structural/measure-contract",
|
|
15
|
+
"Measure keys match their declared sources",
|
|
16
|
+
"fail",
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
"diagnostic/structural/loss-completeness",
|
|
20
|
+
"Loss records are complete",
|
|
21
|
+
"fail",
|
|
22
|
+
],
|
|
23
|
+
[
|
|
24
|
+
"diagnostic/structural/exposure-completeness",
|
|
25
|
+
"Exposure records are complete and finite",
|
|
26
|
+
"fail",
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
"diagnostic/structural/loss-without-exposure",
|
|
30
|
+
"Loss cells have required exposure",
|
|
31
|
+
"warning",
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"diagnostic/structural/exposure-without-loss",
|
|
35
|
+
"Exposures attach to retained loss cells",
|
|
36
|
+
"warning",
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
"diagnostic/structural/expected-cell-coverage",
|
|
40
|
+
"Expected cells are present",
|
|
41
|
+
"fail",
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
"diagnostic/structural/grouping-consistency",
|
|
45
|
+
"Grouping assignments are consistent",
|
|
46
|
+
"fail",
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
"diagnostic/structural/cached-formula-provenance",
|
|
50
|
+
"Cached formulas retain provenance",
|
|
51
|
+
"warning",
|
|
52
|
+
],
|
|
15
53
|
];
|
|
16
|
-
const codeToCheck = {
|
|
17
|
-
|
|
54
|
+
const codeToCheck = {
|
|
55
|
+
"duplicate-loss-record-id": fixed[0][0],
|
|
56
|
+
"duplicate-claim-snapshot": fixed[0][0],
|
|
57
|
+
"claim-identity-conflict": fixed[0][0],
|
|
58
|
+
"duplicate-aggregate-snapshot": fixed[0][0],
|
|
59
|
+
"duplicate-exposure-identity": fixed[1][0],
|
|
60
|
+
"conflicting-exposure-identity": fixed[1][0],
|
|
61
|
+
"unknown-origin-period": fixed[2][0],
|
|
62
|
+
"unknown-valuation-period": fixed[2][0],
|
|
63
|
+
"valuation-before-origin": fixed[2][0],
|
|
64
|
+
"unsafe-development-age": fixed[2][0],
|
|
65
|
+
"undeclared-loss-measure": fixed[3][0],
|
|
66
|
+
"wrong-source-loss-measure": fixed[3][0],
|
|
67
|
+
"undeclared-exposure-measure": fixed[3][0],
|
|
68
|
+
"wrong-source-exposure-measure": fixed[3][0],
|
|
69
|
+
"incomplete-loss-record": fixed[4][0],
|
|
70
|
+
"missing-exposure-value": fixed[5][0],
|
|
71
|
+
"incomplete-exposure": fixed[5][0],
|
|
72
|
+
"non-finite-exposure": fixed[5][0],
|
|
73
|
+
"loss-without-exposure": fixed[6][0],
|
|
74
|
+
"exposure-without-loss": fixed[7][0],
|
|
75
|
+
"missing-expected-cell": fixed[8][0],
|
|
76
|
+
};
|
|
77
|
+
const sourceSchema = z
|
|
78
|
+
.object({
|
|
79
|
+
artifactId: z.string().min(1),
|
|
80
|
+
sourceFile: z.string().min(1).optional(),
|
|
81
|
+
sourceSheet: z.string().min(1).optional(),
|
|
82
|
+
sourceRow: z.number().int().nonnegative().optional(),
|
|
83
|
+
sourceCell: z.string().min(1).optional(),
|
|
84
|
+
})
|
|
85
|
+
.strict();
|
|
86
|
+
const evidenceSchema = z
|
|
87
|
+
.object({
|
|
88
|
+
groupingAssignments: z.array(z
|
|
89
|
+
.object({
|
|
90
|
+
key: z.string().min(1),
|
|
91
|
+
group: z.string().min(1),
|
|
92
|
+
source: sourceSchema.optional(),
|
|
93
|
+
})
|
|
94
|
+
.strict()),
|
|
95
|
+
cachedFormulas: z.array(z
|
|
96
|
+
.object({
|
|
97
|
+
id: z.string().min(1),
|
|
98
|
+
source: sourceSchema.optional(),
|
|
99
|
+
formula: z
|
|
100
|
+
.string()
|
|
101
|
+
.min(1)
|
|
102
|
+
.refine((value) => value.trim().length > 0, "Formula must contain non-whitespace text")
|
|
103
|
+
.optional(),
|
|
104
|
+
cachedValue: z.number().finite().nullable().optional(),
|
|
105
|
+
declaredFormulaSource: z.boolean(),
|
|
106
|
+
})
|
|
107
|
+
.strict()),
|
|
108
|
+
})
|
|
109
|
+
.strict();
|
|
110
|
+
function freeze(value, seen = new WeakSet()) {
|
|
111
|
+
if (value === null || typeof value !== "object" || seen.has(value))
|
|
112
|
+
return value;
|
|
113
|
+
seen.add(value);
|
|
18
114
|
for (const child of Object.values(value))
|
|
19
|
-
freeze(child);
|
|
20
|
-
Object.freeze(value);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
115
|
+
freeze(child, seen);
|
|
116
|
+
return Object.freeze(value);
|
|
117
|
+
}
|
|
118
|
+
function issuePath(root, path) {
|
|
119
|
+
return `${root}${path.map((part) => (typeof part === "number" ? `[${part}]` : /^[A-Za-z_$][\w$]*$/.test(String(part)) ? `.${String(part)}` : `[${JSON.stringify(String(part))}]`)).join("")}`;
|
|
120
|
+
}
|
|
121
|
+
function compareOptional(left, right, compare) {
|
|
122
|
+
if (left === undefined)
|
|
123
|
+
return right === undefined ? 0 : -1;
|
|
124
|
+
if (right === undefined)
|
|
125
|
+
return 1;
|
|
126
|
+
return compare(left, right);
|
|
127
|
+
}
|
|
128
|
+
function compareSourceArrays(left, right) {
|
|
129
|
+
for (let index = 0; index < Math.min(left.length, right.length); index++) {
|
|
130
|
+
const compared = compareDiagnosticSourceLocations(left[index], right[index]);
|
|
131
|
+
if (compared !== 0)
|
|
132
|
+
return compared;
|
|
133
|
+
}
|
|
134
|
+
return left.length - right.length;
|
|
135
|
+
}
|
|
136
|
+
function findingMergeSkeleton(finding) {
|
|
137
|
+
const context = finding.context;
|
|
138
|
+
const reviewScope = context?.reviewScope;
|
|
139
|
+
return {
|
|
140
|
+
...finding,
|
|
141
|
+
...(context === undefined
|
|
142
|
+
? {}
|
|
143
|
+
: {
|
|
144
|
+
context: {
|
|
145
|
+
...context,
|
|
146
|
+
sources: [],
|
|
147
|
+
...(reviewScope === undefined
|
|
148
|
+
? {}
|
|
149
|
+
: { reviewScope: { ...reviewScope, sources: [] } }),
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function findingMergeKey(finding) {
|
|
155
|
+
return canonicalJson(findingMergeSkeleton(finding));
|
|
156
|
+
}
|
|
157
|
+
function normalizeDataFindings(values) {
|
|
158
|
+
const merged = new Map();
|
|
159
|
+
for (const finding of values) {
|
|
160
|
+
const context = finding.context;
|
|
161
|
+
const reviewScope = context?.reviewScope;
|
|
162
|
+
const key = findingMergeKey(finding);
|
|
163
|
+
const previous = merged.get(key);
|
|
164
|
+
const previousContext = previous?.context;
|
|
165
|
+
const normalizedContext = context === undefined
|
|
166
|
+
? undefined
|
|
167
|
+
: {
|
|
168
|
+
...context,
|
|
169
|
+
...(context.sources === undefined &&
|
|
170
|
+
previousContext?.sources === undefined
|
|
171
|
+
? {}
|
|
172
|
+
: {
|
|
173
|
+
sources: normalizeDiagnosticSourceLocations([
|
|
174
|
+
...(previousContext?.sources ?? []),
|
|
175
|
+
...(context.sources ?? []),
|
|
176
|
+
]),
|
|
177
|
+
}),
|
|
178
|
+
...(reviewScope === undefined
|
|
179
|
+
? {}
|
|
180
|
+
: {
|
|
181
|
+
reviewScope: {
|
|
182
|
+
...reviewScope,
|
|
183
|
+
sources: normalizeDiagnosticSourceLocations([
|
|
184
|
+
...(previousContext?.reviewScope?.sources ?? []),
|
|
185
|
+
...reviewScope.sources,
|
|
186
|
+
]),
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
190
|
+
merged.set(key, {
|
|
191
|
+
...finding,
|
|
192
|
+
...(normalizedContext === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: { context: normalizedContext }),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return [...merged.values()].sort(compareDataFindings);
|
|
198
|
+
}
|
|
199
|
+
function compareDataFindings(left, right) {
|
|
200
|
+
const text = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
201
|
+
const field = (finding, key) => finding.context?.[key];
|
|
202
|
+
const textField = (left, right, key) => compareOptional(field(left, key), field(right, key), text);
|
|
203
|
+
return (text(left.code, right.code) ||
|
|
204
|
+
textField(left, right, "ruleId") ||
|
|
205
|
+
textField(left, right, "measureId") ||
|
|
206
|
+
textField(left, right, "offendingKey") ||
|
|
207
|
+
textField(left, right, "groupingKey") ||
|
|
208
|
+
textField(left, right, "cachedEvidenceId") ||
|
|
209
|
+
textField(left, right, "sourceGroup") ||
|
|
210
|
+
textField(left, right, "group") ||
|
|
211
|
+
textField(left, right, "origin") ||
|
|
212
|
+
textField(left, right, "valuation") ||
|
|
213
|
+
compareOptional(field(left, "developmentAge"), field(right, "developmentAge"), (a, b) => a - b) ||
|
|
214
|
+
textField(left, right, "ageUnit") ||
|
|
215
|
+
textField(left, right, "recordId") ||
|
|
216
|
+
textField(left, right, "claimId") ||
|
|
217
|
+
textField(left, right, "exposureKey") ||
|
|
218
|
+
text(canonicalJson(left.context?.reviewScope === undefined
|
|
219
|
+
? null
|
|
220
|
+
: { ...left.context.reviewScope, sources: [] }), canonicalJson(right.context?.reviewScope === undefined
|
|
221
|
+
? null
|
|
222
|
+
: { ...right.context.reviewScope, sources: [] })) ||
|
|
223
|
+
textField(left, right, "sourceFile") ||
|
|
224
|
+
compareOptional(field(left, "sourceRow"), field(right, "sourceRow"), (a, b) => a - b) ||
|
|
225
|
+
text(left.message, right.message) ||
|
|
226
|
+
compareSourceArrays(left.context?.sources ?? [], right.context?.sources ?? []));
|
|
227
|
+
}
|
|
228
|
+
export function validateDiagnosticReviewEvidence(value, root = "$.evidence") {
|
|
229
|
+
const undefinedPaths = [];
|
|
230
|
+
const stack = [
|
|
231
|
+
{ value, path: root },
|
|
232
|
+
];
|
|
233
|
+
const seen = new WeakSet();
|
|
234
|
+
while (stack.length > 0) {
|
|
235
|
+
const current = stack.pop();
|
|
236
|
+
if (current.value === null ||
|
|
237
|
+
typeof current.value !== "object" ||
|
|
238
|
+
seen.has(current.value))
|
|
239
|
+
continue;
|
|
240
|
+
seen.add(current.value);
|
|
241
|
+
for (const [key, child] of Object.entries(current.value)) {
|
|
242
|
+
const path = Array.isArray(current.value)
|
|
243
|
+
? `${current.path}[${key}]`
|
|
244
|
+
: issuePath(current.path, [key]);
|
|
245
|
+
if (child === undefined)
|
|
246
|
+
undefinedPaths.push(path);
|
|
247
|
+
else
|
|
248
|
+
stack.push({ value: child, path });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (undefinedPaths.length > 0)
|
|
252
|
+
throw new DiagnosticValidationError(undefinedPaths.sort().map((path) => ({
|
|
253
|
+
domain: "input",
|
|
254
|
+
code: "invalid-type",
|
|
255
|
+
path,
|
|
256
|
+
message: "Explicit undefined is not allowed",
|
|
257
|
+
})));
|
|
258
|
+
const parsed = evidenceSchema.safeParse(value);
|
|
259
|
+
if (!parsed.success) {
|
|
260
|
+
throw new DiagnosticValidationError(parsed.error.issues.map((issue) => ({
|
|
261
|
+
domain: "input",
|
|
262
|
+
code: issue.code === "unrecognized_keys"
|
|
263
|
+
? "unknown-key"
|
|
264
|
+
: issue.code === "too_small"
|
|
265
|
+
? "invalid-string"
|
|
266
|
+
: "invalid-type",
|
|
267
|
+
path: issuePath(root, issue.path),
|
|
268
|
+
message: issue.message,
|
|
269
|
+
})));
|
|
270
|
+
}
|
|
271
|
+
const codeUnit = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
272
|
+
parsed.data.groupingAssignments.sort((left, right) => codeUnit(left.key, right.key) ||
|
|
273
|
+
codeUnit(left.group, right.group) ||
|
|
274
|
+
compareOptional(left.source, right.source, compareDiagnosticSourceLocations));
|
|
275
|
+
parsed.data.cachedFormulas.sort((left, right) => codeUnit(left.id, right.id) ||
|
|
276
|
+
compareOptional(left.source, right.source, compareDiagnosticSourceLocations) ||
|
|
277
|
+
compareDiagnosticIdentityValues([left.formula, left.cachedValue, left.declaredFormulaSource], [right.formula, right.cachedValue, right.declaredFormulaSource]));
|
|
278
|
+
return freeze(parsed.data);
|
|
279
|
+
}
|
|
280
|
+
function findingContext(finding) {
|
|
281
|
+
return {
|
|
282
|
+
...(finding.ruleId === undefined ? {} : { ruleId: finding.ruleId }),
|
|
283
|
+
...(finding.measureId === undefined
|
|
284
|
+
? {}
|
|
285
|
+
: { measureId: finding.measureId }),
|
|
286
|
+
...(finding.expressionPath === undefined
|
|
287
|
+
? {}
|
|
288
|
+
: { expressionPath: finding.expressionPath }),
|
|
289
|
+
...(finding.offendingKey === undefined
|
|
290
|
+
? {}
|
|
291
|
+
: { offendingKey: finding.offendingKey }),
|
|
292
|
+
...(finding.sourceGroup === undefined
|
|
293
|
+
? {}
|
|
294
|
+
: { sourceGroup: finding.sourceGroup }),
|
|
295
|
+
...(finding.group === undefined ? {} : { group: finding.group }),
|
|
296
|
+
...(finding.origin === undefined ? {} : { origin: finding.origin }),
|
|
297
|
+
...(finding.valuation === undefined
|
|
298
|
+
? {}
|
|
299
|
+
: { valuation: finding.valuation }),
|
|
300
|
+
...(finding.developmentAge === undefined
|
|
301
|
+
? {}
|
|
302
|
+
: { developmentAge: finding.developmentAge }),
|
|
303
|
+
...(finding.ageUnit === undefined ? {} : { ageUnit: finding.ageUnit }),
|
|
304
|
+
...(finding.recordId === undefined ? {} : { recordId: finding.recordId }),
|
|
305
|
+
...(finding.claimId === undefined ? {} : { claimId: finding.claimId }),
|
|
306
|
+
...(finding.exposureKey === undefined
|
|
307
|
+
? {}
|
|
308
|
+
: { exposureKey: finding.exposureKey }),
|
|
309
|
+
sources: finding.sources,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function structuralChecks(prepared, evidence) {
|
|
26
313
|
const findingsByCheck = new Map(fixed.map(([id]) => [id, []]));
|
|
27
|
-
for (const finding of
|
|
314
|
+
for (const finding of prepared.findings) {
|
|
28
315
|
const id = codeToCheck[finding.code];
|
|
29
316
|
if (id)
|
|
30
|
-
findingsByCheck.get(id).push({
|
|
317
|
+
findingsByCheck.get(id).push({
|
|
318
|
+
code: finding.code,
|
|
319
|
+
message: finding.message,
|
|
320
|
+
context: findingContext(finding),
|
|
321
|
+
});
|
|
31
322
|
}
|
|
32
323
|
if (evidence) {
|
|
33
324
|
const assignments = new Map();
|
|
34
325
|
for (const item of evidence.groupingAssignments) {
|
|
35
|
-
const values = assignments.get(item.key) ??
|
|
36
|
-
values.
|
|
326
|
+
const values = assignments.get(item.key) ?? [];
|
|
327
|
+
values.push(item);
|
|
37
328
|
assignments.set(item.key, values);
|
|
38
329
|
}
|
|
39
|
-
for (const [key,
|
|
40
|
-
if (
|
|
41
|
-
findingsByCheck.get(fixed[9][0]).push({
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
330
|
+
for (const [key, values] of assignments) {
|
|
331
|
+
if (new Set(values.map((item) => item.group)).size > 1)
|
|
332
|
+
findingsByCheck.get(fixed[9][0]).push({
|
|
333
|
+
code: "inconsistent-group-mapping",
|
|
334
|
+
message: "Grouping evidence assigns one key to multiple groups",
|
|
335
|
+
context: {
|
|
336
|
+
groupingKey: key,
|
|
337
|
+
sources: normalizeDiagnosticSourceLocations(values.map((item) => item.source)),
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const failingCached = new Map();
|
|
342
|
+
for (const item of evidence.cachedFormulas) {
|
|
343
|
+
const hasOwnCachedValue = Object.prototype.hasOwnProperty.call(item, "cachedValue");
|
|
344
|
+
if (item.declaredFormulaSource &&
|
|
345
|
+
(item.formula === undefined ||
|
|
346
|
+
!hasOwnCachedValue ||
|
|
347
|
+
item.cachedValue === null ||
|
|
348
|
+
item.source === undefined)) {
|
|
349
|
+
const values = failingCached.get(item.id) ?? [];
|
|
350
|
+
values.push(item);
|
|
351
|
+
failingCached.set(item.id, values);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const [id, values] of failingCached)
|
|
355
|
+
findingsByCheck.get(fixed[10][0]).push({
|
|
356
|
+
code: "cached-formula-provenance",
|
|
357
|
+
message: "Declared formula-derived value lacks complete formula provenance",
|
|
358
|
+
context: {
|
|
359
|
+
cachedEvidenceId: id,
|
|
360
|
+
sources: normalizeDiagnosticSourceLocations(values.map((item) => item.source)),
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const hasExposureMeasures = prepared.definition.definition.measures.some((measure) => measure.source === "exposure");
|
|
365
|
+
const checks = fixed.map(([id, description, severity], index) => {
|
|
366
|
+
if ([1, 5, 6, 7].includes(index) && !hasExposureMeasures)
|
|
367
|
+
return createNotEvaluatedDataCheck(id, description, "the definition declares no exposure measures");
|
|
368
|
+
if (index === 8 && !prepared.expectedCellsProvided)
|
|
369
|
+
return createNotEvaluatedDataCheck(id, description, "the expected-cell grid was omitted");
|
|
370
|
+
if ((index === 9 || index === 10) && evidence === null)
|
|
371
|
+
return createNotEvaluatedDataCheck(id, description, "review evidence was omitted");
|
|
372
|
+
return createStructuredDataCheck(id, description, severity, normalizeDataFindings(findingsByCheck.get(id)));
|
|
373
|
+
});
|
|
374
|
+
return checks;
|
|
375
|
+
}
|
|
376
|
+
function evaluationFindings(item, rule) {
|
|
377
|
+
return [
|
|
378
|
+
...item.expressionOverflows.map((overflowItem) => ({
|
|
379
|
+
code: "diagnostic-expression-overflow",
|
|
380
|
+
message: "Measure expression overflowed",
|
|
381
|
+
context: {
|
|
382
|
+
ruleId: rule.id,
|
|
383
|
+
expressionPath: overflowItem.expressionPath,
|
|
384
|
+
reviewScope: item.scope,
|
|
385
|
+
...(overflowItem.coordinate === null ? {} : overflowItem.coordinate),
|
|
386
|
+
sources: overflowItem.sources,
|
|
387
|
+
},
|
|
388
|
+
})),
|
|
389
|
+
...(item.status === "triggered"
|
|
390
|
+
? [
|
|
391
|
+
{
|
|
392
|
+
code: rule.code,
|
|
393
|
+
message: rule.description,
|
|
394
|
+
context: {
|
|
395
|
+
ruleId: rule.id,
|
|
396
|
+
reviewScope: item.scope,
|
|
397
|
+
sources: item.scope.sources,
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
]
|
|
401
|
+
: []),
|
|
402
|
+
...(item.status === "not-evaluated"
|
|
403
|
+
? [
|
|
404
|
+
{
|
|
405
|
+
code: "diagnostic-review-rule-not-evaluated",
|
|
406
|
+
message: "Diagnostic review rule was not evaluated",
|
|
407
|
+
context: {
|
|
408
|
+
ruleId: rule.id,
|
|
409
|
+
reviewScope: item.scope,
|
|
410
|
+
sources: item.scope.sources,
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
]
|
|
414
|
+
: []),
|
|
415
|
+
];
|
|
416
|
+
}
|
|
417
|
+
export function reviewPreparedDiagnosticData(input) {
|
|
418
|
+
assertPreparedDiagnosticData(input.prepared);
|
|
419
|
+
const evidence = input.evidence === null
|
|
420
|
+
? null
|
|
421
|
+
: validateDiagnosticReviewEvidence(input.evidence);
|
|
422
|
+
const evaluations = evaluateDiagnosticReviewRules(input.prepared);
|
|
423
|
+
const checks = structuralChecks(input.prepared, evidence);
|
|
48
424
|
for (const rule of input.prepared.definition.definition.reviewRules) {
|
|
49
425
|
const matching = evaluations.filter((item) => item.ruleId === rule.id);
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
426
|
+
const overflow = matching.some((item) => item.expressionOverflows.length > 0);
|
|
427
|
+
const status = overflow ||
|
|
428
|
+
matching.some((item) => item.status === "triggered" && item.severity === "fail")
|
|
429
|
+
? "fail"
|
|
430
|
+
: matching.some((item) => item.status === "triggered")
|
|
431
|
+
? "warning"
|
|
432
|
+
: matching.some((item) => item.status === "not-evaluated")
|
|
433
|
+
? "not-evaluated"
|
|
434
|
+
: "pass";
|
|
435
|
+
const findings = matching.flatMap((item) => evaluationFindings(item, rule));
|
|
436
|
+
const normalizedFindings = normalizeDataFindings(findings);
|
|
437
|
+
checks.push({
|
|
438
|
+
id: rule.id,
|
|
439
|
+
description: rule.description,
|
|
440
|
+
status,
|
|
441
|
+
details: normalizedFindings.slice(0, 20).map((item) => item.message),
|
|
442
|
+
findings: normalizedFindings,
|
|
443
|
+
});
|
|
53
444
|
}
|
|
54
445
|
const report = freeze(summarizeDataChecks(checks));
|
|
55
|
-
const identityBody =
|
|
56
|
-
|
|
446
|
+
const identityBody = projectDiagnosticIdentity({
|
|
447
|
+
definitionIntegrity: input.prepared.definition.definitionIntegrity,
|
|
448
|
+
preparationFingerprint: input.prepared.preparationFingerprint,
|
|
449
|
+
evidence,
|
|
450
|
+
checks: report.checks.map((check) => ({
|
|
451
|
+
id: check.id,
|
|
452
|
+
status: check.status,
|
|
453
|
+
findings: check.findings,
|
|
454
|
+
})),
|
|
455
|
+
summary: report.summary,
|
|
456
|
+
evaluations,
|
|
457
|
+
});
|
|
458
|
+
return freeze({
|
|
459
|
+
definitionIntegrity: input.prepared.definition.definitionIntegrity,
|
|
460
|
+
preparationFingerprint: input.prepared.preparationFingerprint,
|
|
461
|
+
report,
|
|
462
|
+
evaluations,
|
|
463
|
+
evidence,
|
|
464
|
+
identityBody,
|
|
465
|
+
reportFingerprint: `fnv1a64-jcs-v1:${fnv1a64(canonicalJson({ identityVersion: 1, kind: "diagnostic-review-report", review: identityBody }))}`,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
const findingStates = new WeakMap();
|
|
469
|
+
const compactReceipts = new WeakSet();
|
|
470
|
+
const compactReceiptPreparations = new WeakMap();
|
|
471
|
+
const compactReceiptFingerprints = new WeakMap();
|
|
472
|
+
function compactError(message, path = "$") {
|
|
473
|
+
throw new DiagnosticValidationError([
|
|
474
|
+
{ domain: "input", code: "invalid-input-relationship", path, message },
|
|
475
|
+
]);
|
|
476
|
+
}
|
|
477
|
+
export function assertCompactDiagnosticReviewReceipt(value) {
|
|
478
|
+
if (value === null ||
|
|
479
|
+
typeof value !== "object" ||
|
|
480
|
+
!compactReceipts.has(value))
|
|
481
|
+
compactError("Value is not an authentic compact diagnostic review receipt");
|
|
482
|
+
}
|
|
483
|
+
function findingState(store) {
|
|
484
|
+
if (store === null || typeof store !== "object" || !findingStates.has(store))
|
|
485
|
+
compactError("Value is not an authentic compact diagnostic finding store");
|
|
486
|
+
return findingStates.get(store);
|
|
487
|
+
}
|
|
488
|
+
function findingLocation(state, index) {
|
|
489
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= state.count)
|
|
490
|
+
compactError("Finding index is outside this review", "$.index");
|
|
491
|
+
const block = state.blocks.find((block) => index >= block.start && index < block.start + block.ids.length);
|
|
492
|
+
return { block, id: block.ids[index - block.start] };
|
|
493
|
+
}
|
|
494
|
+
const findingQuerySchema = z
|
|
495
|
+
.object({
|
|
496
|
+
checkId: z.string().refine(isDiagnosticToken).optional(),
|
|
497
|
+
offset: z.number().int().nonnegative().safe().optional(),
|
|
498
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
499
|
+
})
|
|
500
|
+
.strict();
|
|
501
|
+
const findingSourceQuerySchema = z
|
|
502
|
+
.object({
|
|
503
|
+
location: z.enum(["context", "scope"]).optional(),
|
|
504
|
+
offset: z.number().int().nonnegative().safe().optional(),
|
|
505
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
506
|
+
})
|
|
507
|
+
.strict();
|
|
508
|
+
function pageResult(items, total, offset) {
|
|
509
|
+
return Object.freeze({
|
|
510
|
+
total,
|
|
511
|
+
offset,
|
|
512
|
+
items: Object.freeze(items),
|
|
513
|
+
nextOffset: offset + items.length < total ? offset + items.length : null,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
export function getDiagnosticReviewFinding(store, index) {
|
|
517
|
+
const state = findingState(store);
|
|
518
|
+
const { block, id } = findingLocation(state, index);
|
|
519
|
+
return Object.freeze({
|
|
520
|
+
index,
|
|
521
|
+
checkId: block.checkId,
|
|
522
|
+
finding: state.table.read(id),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/** Full ordered evidence, including every finding's source lists. */
|
|
526
|
+
export function iterateDiagnosticReviewFindings(store) {
|
|
527
|
+
const state = findingState(store);
|
|
528
|
+
return (function* () {
|
|
529
|
+
for (let index = 0; index < state.count; index++)
|
|
530
|
+
yield getDiagnosticReviewFinding(store, index);
|
|
531
|
+
})();
|
|
532
|
+
}
|
|
533
|
+
/** Summary pages never expand source lists, including high-fanout control totals. */
|
|
534
|
+
export function pageDiagnosticReviewFindings(store, query = {}) {
|
|
535
|
+
const state = findingState(store);
|
|
536
|
+
const issues = diagnosticJsonPreflight(query, "input");
|
|
537
|
+
if (issues.length)
|
|
538
|
+
throw new DiagnosticValidationError(issues);
|
|
539
|
+
const parsed = findingQuerySchema.safeParse(query);
|
|
540
|
+
if (!parsed.success)
|
|
541
|
+
compactError("Invalid finding page query");
|
|
542
|
+
const { checkId, offset = 0, limit = 100 } = parsed.data;
|
|
543
|
+
const blocks = state.blocks.filter((block) => checkId === undefined || block.checkId === checkId);
|
|
544
|
+
const total = blocks.reduce((sum, block) => sum + block.ids.length, 0);
|
|
545
|
+
const items = [];
|
|
546
|
+
let position = 0;
|
|
547
|
+
for (const block of blocks) {
|
|
548
|
+
for (let local = Math.max(0, offset - position); local < block.ids.length && items.length < limit; local++)
|
|
549
|
+
items.push(Object.freeze({
|
|
550
|
+
index: block.start + local,
|
|
551
|
+
checkId: block.checkId,
|
|
552
|
+
finding: state.table.read(block.ids[local], true),
|
|
553
|
+
}));
|
|
554
|
+
position += block.ids.length;
|
|
555
|
+
if (items.length >= limit)
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
return pageResult(items, total, offset);
|
|
559
|
+
}
|
|
560
|
+
export function pageDiagnosticReviewFindingSources(store, index, query = {}) {
|
|
561
|
+
const state = findingState(store);
|
|
562
|
+
const { id } = findingLocation(state, index);
|
|
563
|
+
const issues = diagnosticJsonPreflight(query, "input");
|
|
564
|
+
if (issues.length)
|
|
565
|
+
throw new DiagnosticValidationError(issues);
|
|
566
|
+
const parsed = findingSourceQuerySchema.safeParse(query);
|
|
567
|
+
if (!parsed.success)
|
|
568
|
+
compactError("Invalid finding source-page query");
|
|
569
|
+
const { location = "context", offset = 0, limit = 100 } = parsed.data;
|
|
570
|
+
const context = state.table.property(id, "context");
|
|
571
|
+
const parent = location === "scope"
|
|
572
|
+
? state.table.property(context, "reviewScope")
|
|
573
|
+
: context;
|
|
574
|
+
const sources = state.table.property(parent, "sources");
|
|
575
|
+
if (sources === undefined)
|
|
576
|
+
return pageResult([], 0, offset);
|
|
577
|
+
const count = state.table.length(sources);
|
|
578
|
+
return pageResult(Array.from({ length: Math.max(0, Math.min(limit, count - offset)) }, (_, local) => state.table.read(state.table.arrayItem(sources, offset + local))), count, offset);
|
|
579
|
+
}
|
|
580
|
+
/** Compact owner receipt; identity projection/hash are deliberately deferred. */
|
|
581
|
+
export function reviewPreparedDiagnosticDataCompact(input) {
|
|
582
|
+
assertCompactPreparedDiagnosticData(input.prepared);
|
|
583
|
+
const evidence = input.evidence === null
|
|
584
|
+
? null
|
|
585
|
+
: validateDiagnosticReviewEvidence(input.evidence);
|
|
586
|
+
const evaluations = evaluateDiagnosticReviewRulesCompact(input.prepared);
|
|
587
|
+
const table = new CompactDiagnosticJson();
|
|
588
|
+
const blocks = [];
|
|
589
|
+
const checks = [];
|
|
590
|
+
let findingCount = 0;
|
|
591
|
+
const appendFindings = (checkId, values) => {
|
|
592
|
+
const pending = [];
|
|
593
|
+
const sources = [];
|
|
594
|
+
const sourceIds = new Map();
|
|
595
|
+
const candidates = new Map();
|
|
596
|
+
const mergeSources = (previous, incoming) => {
|
|
597
|
+
if (incoming === undefined)
|
|
598
|
+
return previous;
|
|
599
|
+
const ids = incoming.map((source) => {
|
|
600
|
+
const key = canonicalJson(source);
|
|
601
|
+
let id = sourceIds.get(key);
|
|
602
|
+
if (id === undefined) {
|
|
603
|
+
id = sources.length;
|
|
604
|
+
sources.push(source);
|
|
605
|
+
sourceIds.set(key, id);
|
|
606
|
+
}
|
|
607
|
+
return id;
|
|
608
|
+
});
|
|
609
|
+
if (previous === undefined)
|
|
610
|
+
return ids;
|
|
611
|
+
if (ids.every((id) => previous instanceof Set ? previous.has(id) : previous.includes(id)))
|
|
612
|
+
return previous;
|
|
613
|
+
const union = previous instanceof Set ? previous : new Set(previous);
|
|
614
|
+
for (const id of ids)
|
|
615
|
+
union.add(id);
|
|
616
|
+
return union;
|
|
617
|
+
};
|
|
618
|
+
const sourceValues = (union) => [...(union ?? [])]
|
|
619
|
+
.map((id) => sources[id])
|
|
620
|
+
.sort(compareDiagnosticSourceLocations);
|
|
621
|
+
for (const value of values) {
|
|
622
|
+
const finding = normalizeDataFindings([value])[0];
|
|
623
|
+
const key = findingMergeKey(finding);
|
|
624
|
+
// Hashes index candidates only. Exact source-free keys decide equality.
|
|
625
|
+
const hash = fnv1a64(key);
|
|
626
|
+
const bucket = candidates.get(hash);
|
|
627
|
+
const possible = bucket === undefined
|
|
628
|
+
? []
|
|
629
|
+
: typeof bucket === "number"
|
|
630
|
+
? [bucket]
|
|
631
|
+
: bucket;
|
|
632
|
+
const matching = possible.find((index) => canonicalJson(table.read(pending[index].keyId)) === key);
|
|
633
|
+
let entry;
|
|
634
|
+
if (matching === undefined) {
|
|
635
|
+
const index = pending.length;
|
|
636
|
+
entry = { keyId: table.add(findingMergeSkeleton(finding)) };
|
|
637
|
+
pending.push(entry);
|
|
638
|
+
candidates.set(hash, bucket === undefined ? index : [...possible, index]);
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
entry = pending[matching];
|
|
642
|
+
entry.keyId = table.add(findingMergeSkeleton(finding));
|
|
643
|
+
}
|
|
644
|
+
entry.contextSources = mergeSources(entry.contextSources, finding.context?.sources);
|
|
645
|
+
entry.scopeSources = mergeSources(entry.scopeSources, finding.context?.reviewScope?.sources);
|
|
646
|
+
}
|
|
647
|
+
// Source-free fields decide nearly every comparison. Expand source IDs only
|
|
648
|
+
// for the contract's final tie-break, never for each ordinary comparison.
|
|
649
|
+
pending.sort((a, b) => compareDataFindings(table.read(a.keyId), table.read(b.keyId)) ||
|
|
650
|
+
compareSourceArrays(sourceValues(a.contextSources), sourceValues(b.contextSources)));
|
|
651
|
+
const ids = pending.map((entry) => {
|
|
652
|
+
const skeleton = table.read(entry.keyId);
|
|
653
|
+
if (skeleton.context === undefined)
|
|
654
|
+
return table.add(skeleton);
|
|
655
|
+
const context = { ...skeleton.context };
|
|
656
|
+
if (entry.contextSources === undefined)
|
|
657
|
+
delete context.sources;
|
|
658
|
+
else
|
|
659
|
+
context.sources = sourceValues(entry.contextSources);
|
|
660
|
+
if (context.reviewScope !== undefined)
|
|
661
|
+
context.reviewScope = {
|
|
662
|
+
...context.reviewScope,
|
|
663
|
+
sources: sourceValues(entry.scopeSources),
|
|
664
|
+
};
|
|
665
|
+
// Each final source list is encoded once, after every duplicate was merged.
|
|
666
|
+
return table.add({ ...skeleton, context });
|
|
667
|
+
});
|
|
668
|
+
const block = { checkId, start: findingCount, ids: Uint32Array.from(ids) };
|
|
669
|
+
findingCount += ids.length;
|
|
670
|
+
blocks.push(block);
|
|
671
|
+
return block;
|
|
672
|
+
};
|
|
673
|
+
for (const check of structuralChecks(input.prepared, evidence)) {
|
|
674
|
+
const block = appendFindings(check.id, check.findings);
|
|
675
|
+
checks.push(Object.freeze({
|
|
676
|
+
id: check.id,
|
|
677
|
+
description: check.description,
|
|
678
|
+
status: check.status,
|
|
679
|
+
details: Object.freeze([...check.details]),
|
|
680
|
+
findingCount: block.ids.length,
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
for (const [ruleIndex, rule,] of input.prepared.definition.definition.reviewRules.entries()) {
|
|
684
|
+
const range = evaluations.rules[ruleIndex];
|
|
685
|
+
const counts = range.summary;
|
|
686
|
+
const status = counts.fail > 0
|
|
687
|
+
? "fail"
|
|
688
|
+
: counts.warning > 0
|
|
689
|
+
? "warning"
|
|
690
|
+
: counts.notEvaluated > 0
|
|
691
|
+
? "not-evaluated"
|
|
692
|
+
: "pass";
|
|
693
|
+
const values = function* () {
|
|
694
|
+
if (counts.pass === range.count)
|
|
695
|
+
return;
|
|
696
|
+
for (let index = range.start; index < range.start + range.count; index++) {
|
|
697
|
+
if (getDiagnosticReviewEvaluationSummary(evaluations, index)
|
|
698
|
+
.effectiveStatus === "pass")
|
|
699
|
+
continue;
|
|
700
|
+
yield* evaluationFindings(getDiagnosticReviewEvaluation(evaluations, index), rule);
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
const block = appendFindings(rule.id, values());
|
|
704
|
+
const details = Array.from(block.ids.subarray(0, 20), (id) => table.read(table.property(id, "message")));
|
|
705
|
+
checks.push(Object.freeze({
|
|
706
|
+
id: rule.id,
|
|
707
|
+
description: rule.description,
|
|
708
|
+
status,
|
|
709
|
+
details: Object.freeze(details),
|
|
710
|
+
findingCount: block.ids.length,
|
|
711
|
+
}));
|
|
712
|
+
}
|
|
713
|
+
table.seal();
|
|
714
|
+
const findings = Object.freeze({
|
|
715
|
+
count: findingCount,
|
|
716
|
+
});
|
|
717
|
+
findingStates.set(findings, { table, blocks, count: findingCount });
|
|
718
|
+
const summary = summarizeDataChecks(checks.map((check) => ({
|
|
719
|
+
...check,
|
|
720
|
+
details: [...check.details],
|
|
721
|
+
findings: [],
|
|
722
|
+
}))).summary;
|
|
723
|
+
const receipt = Object.freeze({
|
|
724
|
+
definitionIntegrity: input.prepared.definition.definitionIntegrity,
|
|
725
|
+
report: Object.freeze({
|
|
726
|
+
checks: Object.freeze(checks),
|
|
727
|
+
summary: Object.freeze(summary),
|
|
728
|
+
}),
|
|
729
|
+
evaluations,
|
|
730
|
+
findings,
|
|
731
|
+
evidence,
|
|
732
|
+
});
|
|
733
|
+
compactReceipts.add(receipt);
|
|
734
|
+
compactReceiptPreparations.set(receipt, input.prepared);
|
|
735
|
+
return receipt;
|
|
736
|
+
}
|
|
737
|
+
/** Exact legacy review identity, available only from an authentic immutable owner. */
|
|
738
|
+
export function getCompactDiagnosticReviewReceiptIdentityDocument(receipt) {
|
|
739
|
+
assertCompactDiagnosticReviewReceipt(receipt);
|
|
740
|
+
const state = findingState(receipt.findings);
|
|
741
|
+
const prepared = compactReceiptPreparations.get(receipt);
|
|
742
|
+
return createDiagnosticIdentityObject({
|
|
743
|
+
definitionIntegrity: createDiagnosticIdentityValue(receipt.definitionIntegrity),
|
|
744
|
+
preparationFingerprint: createDiagnosticIdentityValue(getCompactPreparedDiagnosticDataFingerprint(prepared)),
|
|
745
|
+
evidence: createDiagnosticIdentityValue(receipt.evidence),
|
|
746
|
+
checks: createDiagnosticIdentityArray(receipt.report.checks.length, (index) => {
|
|
747
|
+
const check = receipt.report.checks[index];
|
|
748
|
+
const block = state.blocks[index];
|
|
749
|
+
return createDiagnosticIdentityObject({
|
|
750
|
+
id: createDiagnosticIdentityValue(check.id),
|
|
751
|
+
status: createDiagnosticIdentityValue(check.status),
|
|
752
|
+
findings: createDiagnosticIdentityArray(block.ids.length, (local) => state.table.identityDocument(block.ids[local])),
|
|
753
|
+
});
|
|
754
|
+
}),
|
|
755
|
+
summary: createDiagnosticIdentityValue(receipt.report.summary),
|
|
756
|
+
evaluations: getCompactDiagnosticReviewEvaluationsIdentityDocument(receipt.evaluations),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
/** Explicit evidence operation; caches only the small immutable-owner fingerprint. */
|
|
760
|
+
export function getCompactDiagnosticReviewReceiptFingerprint(receipt) {
|
|
761
|
+
assertCompactDiagnosticReviewReceipt(receipt);
|
|
762
|
+
let fingerprint = compactReceiptFingerprints.get(receipt);
|
|
763
|
+
if (fingerprint === undefined) {
|
|
764
|
+
fingerprint = fingerprintDiagnosticIdentity(getCompactDiagnosticReviewReceiptIdentityDocument(receipt), { kind: "diagnostic-review-report", property: "review" });
|
|
765
|
+
compactReceiptFingerprints.set(receipt, fingerprint);
|
|
766
|
+
}
|
|
767
|
+
return fingerprint;
|
|
57
768
|
}
|
|
58
769
|
//# sourceMappingURL=diagnosticPreparedReview.js.map
|