@actuarial-ts/interchange 0.2.0 → 0.3.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 +28 -1
- package/dist/convert/result.d.ts +4 -4
- package/dist/convert/result.js +3 -3
- package/dist/convert/result.js.map +1 -1
- package/dist/envelope.d.ts +1 -1
- package/dist/envelope.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +49 -0
- package/dist/parse.js.map +1 -1
- package/dist/referee/crosscheck.d.ts +12 -0
- package/dist/referee/crosscheck.d.ts.map +1 -1
- package/dist/referee/crosscheck.js +109 -9
- package/dist/referee/crosscheck.js.map +1 -1
- package/dist/referee/crosscheckStochastic.d.ts +24 -0
- package/dist/referee/crosscheckStochastic.d.ts.map +1 -0
- package/dist/referee/crosscheckStochastic.js +367 -0
- package/dist/referee/crosscheckStochastic.js.map +1 -0
- package/dist/schemas/result.d.ts +552 -0
- package/dist/schemas/result.d.ts.map +1 -1
- package/dist/schemas/result.js +52 -0
- package/dist/schemas/result.js.map +1 -1
- package/package.json +4 -2
- package/src/convert/result.ts +212 -0
- package/src/convert/selection.ts +565 -0
- package/src/convert/triangle.ts +208 -0
- package/src/envelope.ts +193 -0
- package/src/index.ts +15 -0
- package/src/parse.ts +175 -0
- package/src/referee/crosscheck.ts +459 -0
- package/src/referee/crosscheckStochastic.ts +488 -0
- package/src/referee/profiles.ts +113 -0
- package/src/schemas/bundle.ts +64 -0
- package/src/schemas/crosscheck.ts +84 -0
- package/src/schemas/manifest.ts +75 -0
- package/src/schemas/result.ts +180 -0
- package/src/schemas/selection.ts +175 -0
- package/src/schemas/study.ts +98 -0
- package/src/schemas/triangle.ts +138 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { ReservingError } from "@actuarial-ts/core";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_GENERATOR,
|
|
4
|
+
INTERCHANGE_SPEC_VERSION,
|
|
5
|
+
type GeneratorStamp,
|
|
6
|
+
stampIntegrity,
|
|
7
|
+
verifyIntegrity,
|
|
8
|
+
} from "../envelope.js";
|
|
9
|
+
import {
|
|
10
|
+
type MethodResultDoc,
|
|
11
|
+
methodResultDocSchema,
|
|
12
|
+
} from "../schemas/result.js";
|
|
13
|
+
import {
|
|
14
|
+
type SelectionDoc,
|
|
15
|
+
isValueOnlySelection,
|
|
16
|
+
selectionDocSchema,
|
|
17
|
+
} from "../schemas/selection.js";
|
|
18
|
+
import {
|
|
19
|
+
type CrosscheckBody,
|
|
20
|
+
type CrosscheckReportDoc,
|
|
21
|
+
crosscheckReportDocSchema,
|
|
22
|
+
} from "../schemas/crosscheck.js";
|
|
23
|
+
import { CONVENTION_PROFILES } from "./profiles.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The referee (spec 5): deterministic cross-implementation comparison of
|
|
27
|
+
* two MethodResultDocs. NOT an agent (G5) — no judgment, no persuasion,
|
|
28
|
+
* just tags, deviations, tolerances, and a verdict.
|
|
29
|
+
*
|
|
30
|
+
* - Comparability: same triangle tag; same selection tag or both null;
|
|
31
|
+
* convention profiles must not conflict; origin sets must match.
|
|
32
|
+
* - Tolerance: explicit option > shared known profile > the
|
|
33
|
+
* deterministic-cl default (with a warning). mack1993-vw compares SEs at
|
|
34
|
+
* its own SE tolerance; a profile with SEs out of scope skips them.
|
|
35
|
+
* - requested ≠ effective (`effectiveParameters` present on either side)
|
|
36
|
+
* downgrades the comparison with a comparability warning (spec 3.2).
|
|
37
|
+
* - `verified-by-value`: within tolerance but the selection both results
|
|
38
|
+
* replayed is value-only — the engines applied the same values rather
|
|
39
|
+
* than independently recomputing, and the verdict says so.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export interface CrosscheckOptions {
|
|
43
|
+
a: MethodResultDoc;
|
|
44
|
+
b: MethodResultDoc;
|
|
45
|
+
/** Overrides the profile tolerance. A bare number applies to central
|
|
46
|
+
* estimates AND standard errors. */
|
|
47
|
+
tolerance?: number | { central: number; standardError?: number };
|
|
48
|
+
/**
|
|
49
|
+
* Absolute floor for the deviation test, applied as
|
|
50
|
+
* `|x - y| <= absoluteTolerance + rtol * max(|x|, |y|)`. Default 0, which is
|
|
51
|
+
* pure relative comparison.
|
|
52
|
+
*
|
|
53
|
+
* There is deliberately no non-zero default: the referee sees result
|
|
54
|
+
* documents, not the triangle, so it cannot read `units.scale` and any
|
|
55
|
+
* constant it invented would be wrong for somebody's data. A caller who
|
|
56
|
+
* knows the scale should set it — without one, an exact 0 against 1e-11 of
|
|
57
|
+
* float dust reads as a 100% deviation.
|
|
58
|
+
*/
|
|
59
|
+
absoluteTolerance?: number;
|
|
60
|
+
/** The selection both results applied, when one exists — enables the
|
|
61
|
+
* verified-by-value classification. */
|
|
62
|
+
selection?: SelectionDoc;
|
|
63
|
+
/** ISO timestamp for the report envelope (caller-supplied; purity rule). */
|
|
64
|
+
createdAt: string;
|
|
65
|
+
generator?: GeneratorStamp;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ResolvedTolerance {
|
|
69
|
+
central: number;
|
|
70
|
+
standardError: number | null;
|
|
71
|
+
/**
|
|
72
|
+
* True when something AFFIRMATIVELY required standard errors to be compared —
|
|
73
|
+
* a convention profile that scopes them in, or a caller naming
|
|
74
|
+
* `standardError` explicitly. A bare numeric tolerance broadcasts to both
|
|
75
|
+
* metrics as a convenience and does NOT assert that SEs must be present, so
|
|
76
|
+
* it does not make their absence a comparability failure.
|
|
77
|
+
*/
|
|
78
|
+
standardErrorRequired: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function relativeDeviation(x: number, y: number): number {
|
|
82
|
+
const scale = Math.max(Math.abs(x), Math.abs(y));
|
|
83
|
+
return scale === 0 ? 0 : Math.abs(x - y) / scale;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when x and y differ by more than the caller allows.
|
|
88
|
+
*
|
|
89
|
+
* `|x - y| <= atol + rtol * max(|x|, |y|)` — the standard combined form. With
|
|
90
|
+
* atol 0 this is exactly the relative test, which is the default.
|
|
91
|
+
*/
|
|
92
|
+
function exceeds(x: number, y: number, rtol: number, atol: number): boolean {
|
|
93
|
+
return Math.abs(x - y) > atol + rtol * Math.max(Math.abs(x), Math.abs(y));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateInput(label: "a" | "b", doc: MethodResultDoc): MethodResultDoc {
|
|
97
|
+
const parsed = methodResultDocSchema.safeParse(doc);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
// A stochastic result is a legitimate document that simply belongs to the
|
|
100
|
+
// other referee: comparing two Monte Carlo samples under a deterministic
|
|
101
|
+
// tolerance manufactures `disagree` verdicts out of sampling noise
|
|
102
|
+
// (spec 16). Say so, rather than leaving the caller with a schema dump.
|
|
103
|
+
if ((doc as { kind?: unknown })?.kind === "stochastic-result") {
|
|
104
|
+
throw new ReservingError(
|
|
105
|
+
"BAD_INTERCHANGE",
|
|
106
|
+
`crosscheck input "${label}" is a stochastic-result document. Use crosscheckStochastic(), ` +
|
|
107
|
+
"which compares distributions under a tolerance derived from sampling theory; a " +
|
|
108
|
+
"deterministic tolerance applied to two Monte Carlo samples reports ordinary sampling " +
|
|
109
|
+
"noise as disagreement.",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
throw new ReservingError(
|
|
113
|
+
"BAD_INTERCHANGE",
|
|
114
|
+
`crosscheck input "${label}" is not a valid MethodResultDoc: ${parsed.error.issues
|
|
115
|
+
.map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
|
|
116
|
+
.join("; ")}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const integrity = verifyIntegrity(parsed.data);
|
|
120
|
+
if (!integrity.ok) {
|
|
121
|
+
throw new ReservingError(
|
|
122
|
+
"BAD_INTERCHANGE",
|
|
123
|
+
`crosscheck input "${label}" fails its integrity check: stated ${integrity.actual ?? "(none)"}, ` +
|
|
124
|
+
`semantic body hashes to ${integrity.expected}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return parsed.data;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveTolerance(
|
|
131
|
+
a: MethodResultDoc,
|
|
132
|
+
b: MethodResultDoc,
|
|
133
|
+
explicit: CrosscheckOptions["tolerance"],
|
|
134
|
+
warnings: string[],
|
|
135
|
+
): ResolvedTolerance {
|
|
136
|
+
if (typeof explicit === "number") {
|
|
137
|
+
return { central: explicit, standardError: explicit, standardErrorRequired: false };
|
|
138
|
+
}
|
|
139
|
+
if (explicit !== undefined) {
|
|
140
|
+
return {
|
|
141
|
+
central: explicit.central,
|
|
142
|
+
standardError: explicit.standardError ?? null,
|
|
143
|
+
standardErrorRequired: explicit.standardError !== undefined,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const profileA = a.result.engine.conventionProfile;
|
|
147
|
+
const profileB = b.result.engine.conventionProfile;
|
|
148
|
+
// A profile stated on ONE side still governs. Falling back to the default
|
|
149
|
+
// when only one engine declares its conventions silently drops standard
|
|
150
|
+
// errors out of scope, which is how a 50% SE divergence used to report
|
|
151
|
+
// `agree`. Use the stated profile and say the other side did not state one.
|
|
152
|
+
const stated = profileA ?? profileB;
|
|
153
|
+
if (profileA !== undefined && profileB !== undefined && profileA !== profileB) {
|
|
154
|
+
// Conflicting profiles are a comparability failure, handled by the caller.
|
|
155
|
+
} else if (stated !== undefined && profileA !== profileB) {
|
|
156
|
+
warnings.push(
|
|
157
|
+
`Only ${profileA !== undefined ? '"a"' : '"b"'} states a convention profile ` +
|
|
158
|
+
`("${stated}"); it is being applied to both. The other engine did not declare ` +
|
|
159
|
+
"its conventions, so alignment is asserted rather than verified.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const shared = profileA === profileB ? profileA : stated;
|
|
163
|
+
if (shared !== undefined) {
|
|
164
|
+
const profile = CONVENTION_PROFILES[shared];
|
|
165
|
+
if (profile !== undefined) {
|
|
166
|
+
return {
|
|
167
|
+
...profile.tolerance,
|
|
168
|
+
standardErrorRequired: profile.tolerance.standardError !== null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
warnings.push(
|
|
172
|
+
`Convention profile "${shared}" is not in this package's registry; falling back to the ` +
|
|
173
|
+
"deterministic-cl central tolerance",
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
warnings.push(
|
|
177
|
+
"No shared convention profile is stated on both results; falling back to the " +
|
|
178
|
+
"deterministic-cl central tolerance",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const fallback = CONVENTION_PROFILES["deterministic-cl"]!;
|
|
182
|
+
return { ...fallback.tolerance, standardErrorRequired: false };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function crosscheck(options: CrosscheckOptions): CrosscheckReportDoc {
|
|
186
|
+
const a = validateInput("a", options.a);
|
|
187
|
+
const b = validateInput("b", options.b);
|
|
188
|
+
|
|
189
|
+
const absoluteTolerance = options.absoluteTolerance ?? 0;
|
|
190
|
+
const warnings: string[] = [];
|
|
191
|
+
const notComparable: string[] = [];
|
|
192
|
+
|
|
193
|
+
// --- comparability: appliesTo tags (spec 5) ---
|
|
194
|
+
const aTo = a.result.appliesTo;
|
|
195
|
+
const bTo = b.result.appliesTo;
|
|
196
|
+
const sameTriangle = aTo.triangleIntegrity === bTo.triangleIntegrity;
|
|
197
|
+
const sameSelection = aTo.selectionIntegrity === bTo.selectionIntegrity;
|
|
198
|
+
if (!sameTriangle) {
|
|
199
|
+
notComparable.push(
|
|
200
|
+
`the results apply to different triangles (${aTo.triangleIntegrity} vs ${bTo.triangleIntegrity})`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (!sameSelection) {
|
|
204
|
+
notComparable.push(
|
|
205
|
+
`the results apply to different selections (${aTo.selectionIntegrity ?? "null"} vs ` +
|
|
206
|
+
`${bTo.selectionIntegrity ?? "null"}); comparability requires the same selection or both null`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- comparability: convention profiles ---
|
|
211
|
+
const profileA = a.result.engine.conventionProfile;
|
|
212
|
+
const profileB = b.result.engine.conventionProfile;
|
|
213
|
+
if (profileA !== undefined && profileB !== undefined && profileA !== profileB) {
|
|
214
|
+
notComparable.push(
|
|
215
|
+
`the results claim different convention profiles ("${profileA}" vs "${profileB}")`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --- the selection both results replayed, when supplied ---
|
|
220
|
+
let selection: SelectionDoc | null = null;
|
|
221
|
+
if (options.selection !== undefined) {
|
|
222
|
+
const parsed = selectionDocSchema.safeParse(options.selection);
|
|
223
|
+
if (!parsed.success) {
|
|
224
|
+
throw new ReservingError(
|
|
225
|
+
"BAD_INTERCHANGE",
|
|
226
|
+
`crosscheck "selection" is not a valid SelectionDoc: ${parsed.error.issues
|
|
227
|
+
.map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
|
|
228
|
+
.join("; ")}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
selection = parsed.data;
|
|
232
|
+
if (sameSelection && aTo.selectionIntegrity !== selection.integrity) {
|
|
233
|
+
throw new ReservingError(
|
|
234
|
+
"BAD_INTERCHANGE",
|
|
235
|
+
`The supplied selection's integrity (${selection.integrity}) does not match the results' ` +
|
|
236
|
+
`selectionIntegrity (${aTo.selectionIntegrity ?? "null"})`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- comparability: origin sets ---
|
|
242
|
+
const aOrigins = a.result.rows.map((r) => r.origin);
|
|
243
|
+
for (const [label, rows] of [["a", a.result.rows] as const, ["b", b.result.rows] as const]) {
|
|
244
|
+
const seen = new Set<string>();
|
|
245
|
+
for (const row of rows) {
|
|
246
|
+
if (seen.has(row.origin)) {
|
|
247
|
+
throw new ReservingError(
|
|
248
|
+
"BAD_INTERCHANGE",
|
|
249
|
+
`Result "${label}" lists origin "${row.origin}" more than once; per-origin comparison is ill-defined`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
seen.add(row.origin);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const bByOrigin = new Map(b.result.rows.map((r) => [r.origin, r]));
|
|
256
|
+
const aOnly = aOrigins.filter((o) => !bByOrigin.has(o));
|
|
257
|
+
const bOnly = b.result.rows.map((r) => r.origin).filter((o) => !a.result.rows.some((r) => r.origin === o));
|
|
258
|
+
if (aOnly.length > 0 || bOnly.length > 0) {
|
|
259
|
+
notComparable.push(
|
|
260
|
+
`the results cover different origin sets (only in a: [${aOnly.join(", ")}]; only in b: [${bOnly.join(", ")}])`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- requested vs effective downgrade (spec 3.2 / 5) ---
|
|
265
|
+
for (const [label, doc] of [
|
|
266
|
+
["a", a],
|
|
267
|
+
["b", b],
|
|
268
|
+
] as const) {
|
|
269
|
+
if (doc.result.effectiveParameters !== undefined) {
|
|
270
|
+
warnings.push(
|
|
271
|
+
`Comparability downgrade: engine "${label}" (${doc.result.engine.name}) ran with effective ` +
|
|
272
|
+
`parameters ${JSON.stringify(doc.result.effectiveParameters)} deviating from the requested ` +
|
|
273
|
+
`${JSON.stringify(doc.result.parameters)}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const tolerance = resolveTolerance(a, b, options.tolerance, warnings);
|
|
279
|
+
const compareSe = tolerance.standardError !== null;
|
|
280
|
+
if (!compareSe) {
|
|
281
|
+
const seOnBoth =
|
|
282
|
+
a.result.rows.some((r) => r.standardError !== undefined) &&
|
|
283
|
+
b.result.rows.some((r) => r.standardError !== undefined);
|
|
284
|
+
if (seOnBoth) {
|
|
285
|
+
warnings.push(
|
|
286
|
+
"Both results carry standard errors but the applied tolerance has SEs out of scope; " +
|
|
287
|
+
"standard errors were not compared",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- deviations ---
|
|
293
|
+
let comparable = notComparable.length === 0;
|
|
294
|
+
const perOrigin: CrosscheckBody["deviations"]["perOrigin"] = [];
|
|
295
|
+
let maxCentral = 0;
|
|
296
|
+
let maxSe = 0;
|
|
297
|
+
// How many cells each metric was actually compared on. A metric the profile
|
|
298
|
+
// scopes IN but which never got compared on a single cell is the case that
|
|
299
|
+
// used to report `agree` while checking nothing.
|
|
300
|
+
let centralCells = 0;
|
|
301
|
+
let seCells = 0;
|
|
302
|
+
let centralBreached = false;
|
|
303
|
+
let seBreached = false;
|
|
304
|
+
if (comparable) {
|
|
305
|
+
for (const rowA of a.result.rows) {
|
|
306
|
+
const rowB = bByOrigin.get(rowA.origin)!;
|
|
307
|
+
const ultimate = relativeDeviation(rowA.ultimate, rowB.ultimate);
|
|
308
|
+
const unpaid = relativeDeviation(rowA.unpaid, rowB.unpaid);
|
|
309
|
+
const standardError =
|
|
310
|
+
compareSe && rowA.standardError !== undefined && rowB.standardError !== undefined
|
|
311
|
+
? relativeDeviation(rowA.standardError, rowB.standardError)
|
|
312
|
+
: null;
|
|
313
|
+
centralCells++;
|
|
314
|
+
maxCentral = Math.max(maxCentral, ultimate, unpaid);
|
|
315
|
+
if (
|
|
316
|
+
exceeds(rowA.ultimate, rowB.ultimate, tolerance.central, absoluteTolerance) ||
|
|
317
|
+
exceeds(rowA.unpaid, rowB.unpaid, tolerance.central, absoluteTolerance)
|
|
318
|
+
) {
|
|
319
|
+
centralBreached = true;
|
|
320
|
+
}
|
|
321
|
+
if (standardError !== null) {
|
|
322
|
+
seCells++;
|
|
323
|
+
maxSe = Math.max(maxSe, standardError);
|
|
324
|
+
if (exceeds(rowA.standardError!, rowB.standardError!, tolerance.standardError!, absoluteTolerance)) {
|
|
325
|
+
seBreached = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
perOrigin.push({ origin: rowA.origin, ultimate, unpaid, standardError });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const totalsA = a.result.totals;
|
|
332
|
+
const totalsB = b.result.totals;
|
|
333
|
+
const totals: CrosscheckBody["deviations"]["totals"] = comparable
|
|
334
|
+
? {
|
|
335
|
+
ultimate: relativeDeviation(totalsA.ultimate, totalsB.ultimate),
|
|
336
|
+
unpaid: relativeDeviation(totalsA.unpaid, totalsB.unpaid),
|
|
337
|
+
standardError:
|
|
338
|
+
compareSe &&
|
|
339
|
+
totalsA.standardError !== undefined &&
|
|
340
|
+
totalsB.standardError !== undefined
|
|
341
|
+
? relativeDeviation(totalsA.standardError, totalsB.standardError)
|
|
342
|
+
: null,
|
|
343
|
+
}
|
|
344
|
+
: { ultimate: null, unpaid: null, standardError: null };
|
|
345
|
+
if (comparable) {
|
|
346
|
+
centralCells++;
|
|
347
|
+
maxCentral = Math.max(maxCentral, totals.ultimate ?? 0, totals.unpaid ?? 0);
|
|
348
|
+
if (
|
|
349
|
+
exceeds(totalsA.ultimate, totalsB.ultimate, tolerance.central, absoluteTolerance) ||
|
|
350
|
+
exceeds(totalsA.unpaid, totalsB.unpaid, tolerance.central, absoluteTolerance)
|
|
351
|
+
) {
|
|
352
|
+
centralBreached = true;
|
|
353
|
+
}
|
|
354
|
+
if (totals.standardError !== null) {
|
|
355
|
+
seCells++;
|
|
356
|
+
maxSe = Math.max(maxSe, totals.standardError);
|
|
357
|
+
if (exceeds(totalsA.standardError!, totalsB.standardError!, tolerance.standardError!, absoluteTolerance)) {
|
|
358
|
+
seBreached = true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// A metric the profile scopes IN but which was never compared on any cell
|
|
364
|
+
// leaves the comparison incomplete. `agree` is read as "these engines agree",
|
|
365
|
+
// so it must not be reachable when the thing the profile asked about was
|
|
366
|
+
// never checked — the referee refuses instead, and says which metric.
|
|
367
|
+
const uncovered: string[] = [];
|
|
368
|
+
if (comparable && centralCells === 0) {
|
|
369
|
+
uncovered.push("central estimates were not compared on any cell");
|
|
370
|
+
}
|
|
371
|
+
if (comparable && compareSe && tolerance.standardErrorRequired && seCells === 0) {
|
|
372
|
+
uncovered.push(
|
|
373
|
+
`the profile scopes standard errors in (tolerance ${tolerance.standardError}) but neither ` +
|
|
374
|
+
"side carries a standard error on any cell, so they were never compared",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --- verdict ---
|
|
379
|
+
let verdict: CrosscheckBody["verdict"];
|
|
380
|
+
if (!comparable) {
|
|
381
|
+
verdict = "not-comparable";
|
|
382
|
+
warnings.push(...notComparable.map((reason) => `Not comparable: ${reason}`));
|
|
383
|
+
} else if (centralBreached || (compareSe && seBreached)) {
|
|
384
|
+
verdict = "disagree";
|
|
385
|
+
} else if (uncovered.length > 0) {
|
|
386
|
+
verdict = "not-comparable";
|
|
387
|
+
warnings.push(
|
|
388
|
+
...uncovered.map(
|
|
389
|
+
(reason) =>
|
|
390
|
+
`Not comparable: ${reason}. Everything that WAS compared agreed, but a verdict of ` +
|
|
391
|
+
"`agree` would overstate that.",
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
} else if (
|
|
395
|
+
selection !== null &&
|
|
396
|
+
sameSelection &&
|
|
397
|
+
aTo.selectionIntegrity !== null &&
|
|
398
|
+
isValueOnlySelection(selection.selection)
|
|
399
|
+
) {
|
|
400
|
+
verdict = "verified-by-value";
|
|
401
|
+
warnings.push(
|
|
402
|
+
"The compared results replayed a value-only selection (every intent judgmental/external): " +
|
|
403
|
+
"no independent recomputation occurred; agreement verifies value transport, not methodology",
|
|
404
|
+
);
|
|
405
|
+
} else {
|
|
406
|
+
if (sameSelection && aTo.selectionIntegrity !== null && selection === null) {
|
|
407
|
+
// Whether agreement reflects independent recomputation or a replay of the
|
|
408
|
+
// same values depends on the selection document — and the party supplying
|
|
409
|
+
// it is the one being audited. Silence here let them choose whether the
|
|
410
|
+
// disclosure appeared, so say plainly that the check did not run.
|
|
411
|
+
warnings.push(
|
|
412
|
+
`Both results replayed selection ${aTo.selectionIntegrity}, but the selection document ` +
|
|
413
|
+
"was not supplied, so whether it is value-only could not be checked. If it is, this " +
|
|
414
|
+
"agreement verifies value transport rather than methodology — pass `selection` to " +
|
|
415
|
+
"resolve it.",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
verdict = "agree";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const body: CrosscheckBody = {
|
|
422
|
+
engines: { a: a.result.engine, b: b.result.engine },
|
|
423
|
+
appliesTo: sameTriangle && sameSelection ? aTo : null,
|
|
424
|
+
parameters: {
|
|
425
|
+
a: { requested: a.result.parameters, effective: a.result.effectiveParameters ?? null },
|
|
426
|
+
b: { requested: b.result.parameters, effective: b.result.effectiveParameters ?? null },
|
|
427
|
+
},
|
|
428
|
+
tolerance: { central: tolerance.central, standardError: tolerance.standardError },
|
|
429
|
+
deviations: { perOrigin, totals },
|
|
430
|
+
verdict,
|
|
431
|
+
warnings,
|
|
432
|
+
// What was actually examined, so a reader can tell "agreed on everything the
|
|
433
|
+
// profile asked about" from "agreed on what happened to be present".
|
|
434
|
+
coverage: {
|
|
435
|
+
central: { inScope: true, comparedCells: centralCells },
|
|
436
|
+
standardError: { inScope: compareSe, comparedCells: seCells },
|
|
437
|
+
absoluteTolerance,
|
|
438
|
+
},
|
|
439
|
+
} as CrosscheckBody;
|
|
440
|
+
|
|
441
|
+
const doc = stampIntegrity<CrosscheckReportDoc>({
|
|
442
|
+
interchangeVersion: INTERCHANGE_SPEC_VERSION,
|
|
443
|
+
kind: "crosscheck-report",
|
|
444
|
+
generator: options.generator ?? DEFAULT_GENERATOR,
|
|
445
|
+
createdAt: options.createdAt,
|
|
446
|
+
extensions: {},
|
|
447
|
+
report: body,
|
|
448
|
+
});
|
|
449
|
+
const checked = crosscheckReportDocSchema.safeParse(doc);
|
|
450
|
+
if (!checked.success) {
|
|
451
|
+
throw new ReservingError(
|
|
452
|
+
"BAD_INTERCHANGE",
|
|
453
|
+
`crosscheck produced an invalid report: ${checked.error.issues
|
|
454
|
+
.map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
|
|
455
|
+
.join("; ")}`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
return checked.data;
|
|
459
|
+
}
|