@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,565 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AverageKey,
|
|
3
|
+
type AverageSpec,
|
|
4
|
+
type LdfSelections,
|
|
5
|
+
ReservingError,
|
|
6
|
+
type TailMethod,
|
|
7
|
+
type Triangle,
|
|
8
|
+
computeDevelopmentFactors,
|
|
9
|
+
fitTail,
|
|
10
|
+
triangleFromGrid,
|
|
11
|
+
} from "@actuarial-ts/core";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_GENERATOR,
|
|
14
|
+
INTERCHANGE_SPEC_VERSION,
|
|
15
|
+
type GeneratorStamp,
|
|
16
|
+
stampIntegrity,
|
|
17
|
+
} from "../envelope.js";
|
|
18
|
+
import type { OriginLengthMonths, TriangleDoc } from "../schemas/triangle.js";
|
|
19
|
+
import {
|
|
20
|
+
type DevelopmentIntent,
|
|
21
|
+
type DevelopmentSelection,
|
|
22
|
+
type SelectionBody,
|
|
23
|
+
type SelectionDoc,
|
|
24
|
+
type TailFamily,
|
|
25
|
+
type TailIntent,
|
|
26
|
+
VALUE_AUTHORITATIVE_KINDS,
|
|
27
|
+
selectionDocSchema,
|
|
28
|
+
} from "../schemas/selection.js";
|
|
29
|
+
import { docToTriangle } from "./triangle.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* core `LdfSelections` ↔ SelectionDoc, with the coherence rule (spec 3.2).
|
|
33
|
+
*
|
|
34
|
+
* THE COHERENCE RULE (normative): for COMPUTABLE intents (everything
|
|
35
|
+
* except judgmental/external), `value` MUST equal the intent's
|
|
36
|
+
* recomputation on the referenced triangle within 1e-9 relative. Every
|
|
37
|
+
* conforming importer verifies this and, per a strictness flag, warns or
|
|
38
|
+
* refuses (INCOHERENT_SELECTION) on divergence. Intent is authoritative
|
|
39
|
+
* for replay and promotion; values are authoritative only for
|
|
40
|
+
* judgmental/external intents.
|
|
41
|
+
*
|
|
42
|
+
* REPLAY CAPABILITY (spec 3.2 equivalence table): this shore recomputes
|
|
43
|
+
* only the cells the table marks `exact` for actuarial-ts —
|
|
44
|
+
* volume-weighted/simple all-period on any cadence, windowed n ∈ {5, 3}
|
|
45
|
+
* on 12-month cadence, geometric all-period, and medial {5, 1, 1} — via
|
|
46
|
+
* core's computeDevelopmentFactors, plus fitted tails via core's fitTail.
|
|
47
|
+
* Everything else (regression, other windows/trims, and any intent with
|
|
48
|
+
* origin exclusions, which core's menu machinery does not model) is
|
|
49
|
+
* value-only HERE: the value is accepted with a warning naming the
|
|
50
|
+
* limitation, never silently, and never recomputed with semantics the
|
|
51
|
+
* table does not claim. Cross-engine verification of those cells is the
|
|
52
|
+
* conformance suite's and the referee's business.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** Spec coherence tolerance: 1e-9 relative (spec 3.2). */
|
|
56
|
+
export const COHERENCE_TOLERANCE = 1e-9;
|
|
57
|
+
|
|
58
|
+
/** Menu key → interchange intent (the DEFAULT_AVERAGES mapping target). */
|
|
59
|
+
const MENU_INTENTS: Record<AverageKey, DevelopmentIntent> = {
|
|
60
|
+
"all-wtd": { kind: "volume-weighted" },
|
|
61
|
+
"all-str": { kind: "simple" },
|
|
62
|
+
"5-wtd": { kind: "volume-weighted", windowOriginPeriods: 5 },
|
|
63
|
+
"5-str": { kind: "simple", windowOriginPeriods: 5 },
|
|
64
|
+
"3-wtd": { kind: "volume-weighted", windowOriginPeriods: 3 },
|
|
65
|
+
"3-str": { kind: "simple", windowOriginPeriods: 3 },
|
|
66
|
+
"med-5x1": { kind: "medial", windowOriginPeriods: 5, excludeHigh: 1, excludeLow: 1 },
|
|
67
|
+
"geo-all": { kind: "geometric" },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const MENU_KEYS = Object.keys(MENU_INTENTS) as AverageKey[];
|
|
71
|
+
|
|
72
|
+
/** The interchange intent a standard-menu average key expresses. */
|
|
73
|
+
export function intentFromAverageKey(key: AverageKey): DevelopmentIntent {
|
|
74
|
+
return { ...MENU_INTENTS[key] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hasExclusions(intent: DevelopmentIntent): boolean {
|
|
78
|
+
return intent.exclusions !== undefined && intent.exclusions.length > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The standard-menu key that replays this intent EXACTLY on the given
|
|
83
|
+
* cadence per the spec 3.2 equivalence table, or null (value-only here).
|
|
84
|
+
*/
|
|
85
|
+
export function averageKeyForIntent(
|
|
86
|
+
intent: DevelopmentIntent,
|
|
87
|
+
originLengthMonths: OriginLengthMonths,
|
|
88
|
+
): AverageKey | null {
|
|
89
|
+
if (hasExclusions(intent)) return null;
|
|
90
|
+
const window = intent.windowOriginPeriods;
|
|
91
|
+
switch (intent.kind) {
|
|
92
|
+
case "volume-weighted":
|
|
93
|
+
if (window === undefined) return "all-wtd";
|
|
94
|
+
if (originLengthMonths === 12 && (window === 5 || window === 3)) {
|
|
95
|
+
return window === 5 ? "5-wtd" : "3-wtd";
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
case "simple":
|
|
99
|
+
if (window === undefined) return "all-str";
|
|
100
|
+
if (originLengthMonths === 12 && (window === 5 || window === 3)) {
|
|
101
|
+
return window === 5 ? "5-str" : "3-str";
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
case "geometric":
|
|
105
|
+
return window === undefined ? "geo-all" : null;
|
|
106
|
+
case "medial":
|
|
107
|
+
return originLengthMonths === 12 &&
|
|
108
|
+
window === 5 &&
|
|
109
|
+
intent.excludeHigh === 1 &&
|
|
110
|
+
intent.excludeLow === 1
|
|
111
|
+
? "med-5x1"
|
|
112
|
+
: null;
|
|
113
|
+
default:
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isValueAuthoritative(kind: string): boolean {
|
|
119
|
+
return (VALUE_AUTHORITATIVE_KINDS as readonly string[]).includes(kind);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The AverageSpec core recomputes an exactly-replayable intent with. */
|
|
123
|
+
function specForExactIntent(key: AverageKey): AverageSpec {
|
|
124
|
+
const kindByKey: Record<AverageKey, AverageSpec["kind"]> = {
|
|
125
|
+
"all-wtd": "weighted",
|
|
126
|
+
"all-str": "straight",
|
|
127
|
+
"5-wtd": "weighted",
|
|
128
|
+
"5-str": "straight",
|
|
129
|
+
"3-wtd": "weighted",
|
|
130
|
+
"3-str": "straight",
|
|
131
|
+
"med-5x1": "medial",
|
|
132
|
+
"geo-all": "geometric",
|
|
133
|
+
};
|
|
134
|
+
const spec: AverageSpec = { key, label: key, kind: kindByKey[key] };
|
|
135
|
+
const years = MENU_INTENTS[key].windowOriginPeriods;
|
|
136
|
+
if (years !== undefined) spec.years = years;
|
|
137
|
+
return spec;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const TAIL_METHOD_BY_FAMILY: Record<TailFamily, TailMethod> = {
|
|
141
|
+
"exponential-decay": "exponentialDecay",
|
|
142
|
+
"inverse-power": "inversePower",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export interface CoherenceFinding {
|
|
146
|
+
/** The development interval, or "tail". */
|
|
147
|
+
target: { fromAgeMonths: number; toAgeMonths: number } | "tail";
|
|
148
|
+
/** Whether this shore could recompute the intent exactly. */
|
|
149
|
+
capability: "exact" | "value-only";
|
|
150
|
+
/** Which side of the intent/value pair is authoritative (spec 3.2). */
|
|
151
|
+
authoritative: "intent" | "value";
|
|
152
|
+
/** The recomputed value where capability is exact; null otherwise. */
|
|
153
|
+
expected: number | null;
|
|
154
|
+
/** The value the document states. */
|
|
155
|
+
actual: number;
|
|
156
|
+
relativeDeviation: number | null;
|
|
157
|
+
/** true = verified coherent; false = divergent; null = not recomputed. */
|
|
158
|
+
coherent: boolean | null;
|
|
159
|
+
note?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface CoherenceCheck {
|
|
163
|
+
coherent: boolean;
|
|
164
|
+
findings: CoherenceFinding[];
|
|
165
|
+
warnings: string[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function relativeDeviation(actual: number, expected: number): number {
|
|
169
|
+
const scale = Math.max(Math.abs(actual), Math.abs(expected));
|
|
170
|
+
return scale === 0 ? 0 : Math.abs(actual - expected) / scale;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Maps development entries to triangle column indexes; refuses unknown or
|
|
174
|
+
* duplicated intervals. */
|
|
175
|
+
function mapEntriesToColumns(
|
|
176
|
+
development: readonly DevelopmentSelection[],
|
|
177
|
+
agesMonths: readonly number[],
|
|
178
|
+
): number[] {
|
|
179
|
+
const seen = new Set<number>();
|
|
180
|
+
return development.map((entry) => {
|
|
181
|
+
const j = agesMonths.findIndex(
|
|
182
|
+
(age, idx) =>
|
|
183
|
+
age === entry.fromAgeMonths && agesMonths[idx + 1] === entry.toAgeMonths,
|
|
184
|
+
);
|
|
185
|
+
if (j < 0) {
|
|
186
|
+
throw new ReservingError(
|
|
187
|
+
"BAD_INTERCHANGE",
|
|
188
|
+
`Development interval ${entry.fromAgeMonths}→${entry.toAgeMonths} months does not match ` +
|
|
189
|
+
`adjacent triangle ages (agesMonths: ${agesMonths.join(", ")})`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (seen.has(j)) {
|
|
193
|
+
throw new ReservingError(
|
|
194
|
+
"BAD_INTERCHANGE",
|
|
195
|
+
`Duplicate development entry for ${entry.fromAgeMonths}→${entry.toAgeMonths} months`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
seen.add(j);
|
|
199
|
+
return j;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Two-age sub-triangle for column j: recomputation without cross-column effects. */
|
|
204
|
+
function columnSubTriangle(tri: Triangle, j: number): Triangle {
|
|
205
|
+
return triangleFromGrid(
|
|
206
|
+
tri.kind,
|
|
207
|
+
tri.origins,
|
|
208
|
+
[tri.ages[j]!, tri.ages[j + 1]!],
|
|
209
|
+
tri.values.map((row) => [row[j] ?? null, row[j + 1] ?? null]),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface CheckCoherenceOptions {
|
|
214
|
+
/** warn: report divergence; refuse: throw INCOHERENT_SELECTION. */
|
|
215
|
+
strictness: "warn" | "refuse";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Verifies the coherence rule for a selection body against its referenced
|
|
220
|
+
* triangle document. Verifies the appliesTo linkage first (tag + measure);
|
|
221
|
+
* a mismatch is BAD_INTERCHANGE, not incoherence.
|
|
222
|
+
*/
|
|
223
|
+
export function checkSelectionCoherence(
|
|
224
|
+
selection: SelectionBody,
|
|
225
|
+
triangleDoc: TriangleDoc,
|
|
226
|
+
options: CheckCoherenceOptions,
|
|
227
|
+
): CoherenceCheck {
|
|
228
|
+
if (selection.appliesTo.triangleIntegrity !== triangleDoc.integrity) {
|
|
229
|
+
throw new ReservingError(
|
|
230
|
+
"BAD_INTERCHANGE",
|
|
231
|
+
`Selection applies to triangle ${selection.appliesTo.triangleIntegrity} but the supplied ` +
|
|
232
|
+
`triangle document's integrity is ${triangleDoc.integrity}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (selection.appliesTo.measure !== triangleDoc.triangle.measure) {
|
|
236
|
+
throw new ReservingError(
|
|
237
|
+
"BAD_INTERCHANGE",
|
|
238
|
+
`Selection applies to measure "${selection.appliesTo.measure}" but the triangle's measure ` +
|
|
239
|
+
`is "${triangleDoc.triangle.measure}"`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const { triangle } = docToTriangle(triangleDoc);
|
|
244
|
+
const cadence = triangleDoc.triangle.originLengthMonths;
|
|
245
|
+
const agesMonths = triangleDoc.triangle.agesMonths;
|
|
246
|
+
const columns = mapEntriesToColumns(selection.development, agesMonths);
|
|
247
|
+
|
|
248
|
+
const findings: CoherenceFinding[] = [];
|
|
249
|
+
const warnings: string[] = [];
|
|
250
|
+
|
|
251
|
+
selection.development.forEach((entry, idx) => {
|
|
252
|
+
const target = { fromAgeMonths: entry.fromAgeMonths, toAgeMonths: entry.toAgeMonths };
|
|
253
|
+
const label = `${entry.fromAgeMonths}→${entry.toAgeMonths}`;
|
|
254
|
+
if (isValueAuthoritative(entry.intent.kind)) {
|
|
255
|
+
findings.push({
|
|
256
|
+
target,
|
|
257
|
+
capability: "value-only",
|
|
258
|
+
authoritative: "value",
|
|
259
|
+
expected: null,
|
|
260
|
+
actual: entry.value,
|
|
261
|
+
relativeDeviation: null,
|
|
262
|
+
coherent: null,
|
|
263
|
+
note: `intent "${entry.intent.kind}": the value IS the judgment (rationale carries its justification)`,
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const menuKey = averageKeyForIntent(entry.intent, cadence);
|
|
268
|
+
if (menuKey === null) {
|
|
269
|
+
const reason = hasExclusions(entry.intent)
|
|
270
|
+
? "origin exclusions are beyond this shore's exact replay in Phase A"
|
|
271
|
+
: `intent "${entry.intent.kind}"${
|
|
272
|
+
entry.intent.windowOriginPeriods !== undefined
|
|
273
|
+
? ` with windowOriginPeriods ${entry.intent.windowOriginPeriods}`
|
|
274
|
+
: ""
|
|
275
|
+
} on ${cadence}-month cadence is not an exact actuarial-ts replay cell`;
|
|
276
|
+
findings.push({
|
|
277
|
+
target,
|
|
278
|
+
capability: "value-only",
|
|
279
|
+
authoritative: "intent",
|
|
280
|
+
expected: null,
|
|
281
|
+
actual: entry.value,
|
|
282
|
+
relativeDeviation: null,
|
|
283
|
+
coherent: null,
|
|
284
|
+
note: reason,
|
|
285
|
+
});
|
|
286
|
+
warnings.push(
|
|
287
|
+
`Development ${label}: value accepted without recomputation — ${reason} ` +
|
|
288
|
+
"(spec 3.2 equivalence table)",
|
|
289
|
+
);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const recomputed = computeDevelopmentFactors(columnSubTriangle(triangle, columns[idx]!), [
|
|
293
|
+
specForExactIntent(menuKey),
|
|
294
|
+
]).averages[0]!.values[0]!;
|
|
295
|
+
if (recomputed === null) {
|
|
296
|
+
findings.push({
|
|
297
|
+
target,
|
|
298
|
+
capability: "exact",
|
|
299
|
+
authoritative: "intent",
|
|
300
|
+
expected: null,
|
|
301
|
+
actual: entry.value,
|
|
302
|
+
relativeDeviation: null,
|
|
303
|
+
coherent: false,
|
|
304
|
+
note: "the intent produces no factor on this triangle (no usable pairs in the window)",
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const deviation = relativeDeviation(entry.value, recomputed);
|
|
309
|
+
findings.push({
|
|
310
|
+
target,
|
|
311
|
+
capability: "exact",
|
|
312
|
+
authoritative: "intent",
|
|
313
|
+
expected: recomputed,
|
|
314
|
+
actual: entry.value,
|
|
315
|
+
relativeDeviation: deviation,
|
|
316
|
+
coherent: deviation <= COHERENCE_TOLERANCE,
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
if (selection.tail !== undefined) {
|
|
321
|
+
const tail = selection.tail;
|
|
322
|
+
if (isValueAuthoritative(tail.intent.kind)) {
|
|
323
|
+
findings.push({
|
|
324
|
+
target: "tail",
|
|
325
|
+
capability: "value-only",
|
|
326
|
+
authoritative: "value",
|
|
327
|
+
expected: null,
|
|
328
|
+
actual: tail.value,
|
|
329
|
+
relativeDeviation: null,
|
|
330
|
+
coherent: null,
|
|
331
|
+
note: `tail intent "${tail.intent.kind}": the value IS the judgment`,
|
|
332
|
+
});
|
|
333
|
+
} else {
|
|
334
|
+
// Fitted tail: refit core's curve on the selection's own development
|
|
335
|
+
// factors (the fit runs on SELECTED factors), masking columns below
|
|
336
|
+
// fitFromAgeMonths.
|
|
337
|
+
const selectedLdfs: (number | null)[] = new Array(agesMonths.length - 1).fill(null);
|
|
338
|
+
selection.development.forEach((entry, idx) => {
|
|
339
|
+
selectedLdfs[columns[idx]!] =
|
|
340
|
+
tail.intent.kind === "fitted" &&
|
|
341
|
+
tail.intent.fitFromAgeMonths !== undefined &&
|
|
342
|
+
entry.fromAgeMonths < tail.intent.fitFromAgeMonths
|
|
343
|
+
? null
|
|
344
|
+
: entry.value;
|
|
345
|
+
});
|
|
346
|
+
const fit = fitTail({
|
|
347
|
+
method: TAIL_METHOD_BY_FAMILY[(tail.intent as { family: TailFamily }).family],
|
|
348
|
+
selectedLdfs,
|
|
349
|
+
});
|
|
350
|
+
if (!fit.valid) {
|
|
351
|
+
findings.push({
|
|
352
|
+
target: "tail",
|
|
353
|
+
capability: "exact",
|
|
354
|
+
authoritative: "intent",
|
|
355
|
+
expected: null,
|
|
356
|
+
actual: tail.value,
|
|
357
|
+
relativeDeviation: null,
|
|
358
|
+
coherent: false,
|
|
359
|
+
note: `the fitted-tail intent does not produce a valid fit on this selection: ${fit.warnings.join("; ")}`,
|
|
360
|
+
});
|
|
361
|
+
} else {
|
|
362
|
+
const deviation = relativeDeviation(tail.value, fit.tailFactor);
|
|
363
|
+
findings.push({
|
|
364
|
+
target: "tail",
|
|
365
|
+
capability: "exact",
|
|
366
|
+
authoritative: "intent",
|
|
367
|
+
expected: fit.tailFactor,
|
|
368
|
+
actual: tail.value,
|
|
369
|
+
relativeDeviation: deviation,
|
|
370
|
+
coherent: deviation <= COHERENCE_TOLERANCE,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const divergent = findings.filter((f) => f.coherent === false);
|
|
377
|
+
for (const f of divergent) {
|
|
378
|
+
const where = f.target === "tail" ? "tail" : `${f.target.fromAgeMonths}→${f.target.toAgeMonths}`;
|
|
379
|
+
warnings.push(
|
|
380
|
+
`INCOHERENT at ${where}: stated value ${f.actual}` +
|
|
381
|
+
(f.expected !== null
|
|
382
|
+
? ` but the intent recomputes to ${f.expected} (relative deviation ${f.relativeDeviation?.toExponential(3)}, tolerance ${COHERENCE_TOLERANCE})`
|
|
383
|
+
: ` but the intent is not computable on this triangle${f.note !== undefined ? ` (${f.note})` : ""}`),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
if (divergent.length > 0 && options.strictness === "refuse") {
|
|
387
|
+
throw new ReservingError(
|
|
388
|
+
"INCOHERENT_SELECTION",
|
|
389
|
+
`Selection violates the coherence rule (spec 3.2) at ${divergent.length} target(s): ` +
|
|
390
|
+
warnings.filter((w) => w.startsWith("INCOHERENT")).join(" | "),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
return { coherent: divergent.length === 0, findings, warnings };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Per-column intent: a standard-menu key or an explicit interchange intent. */
|
|
397
|
+
export type DevelopmentIntentInput = AverageKey | DevelopmentIntent;
|
|
398
|
+
|
|
399
|
+
export interface SelectionsToDocOptions {
|
|
400
|
+
/** The triangle document the selection applies to (source of the tag). */
|
|
401
|
+
triangleDoc: TriangleDoc;
|
|
402
|
+
/** ISO timestamp for the envelope (caller-supplied; purity rule). */
|
|
403
|
+
createdAt: string;
|
|
404
|
+
/**
|
|
405
|
+
* Per development column j (length = agesMonths.length - 1): the intent
|
|
406
|
+
* behind selected[j]. Required wherever selected[j] is a number; columns
|
|
407
|
+
* with null selections are omitted from the document.
|
|
408
|
+
*/
|
|
409
|
+
intents: readonly (DevelopmentIntentInput | null | undefined)[];
|
|
410
|
+
/** Tail intent; required when selections.tailFactor ≠ 1. */
|
|
411
|
+
tailIntent?: TailIntent;
|
|
412
|
+
/** Authoring-side coherence strictness. Default "refuse": never author
|
|
413
|
+
* an incoherent document silently. */
|
|
414
|
+
strictness?: "warn" | "refuse";
|
|
415
|
+
generator?: GeneratorStamp;
|
|
416
|
+
extensions?: Record<string, unknown>;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export interface SelectionsToDocResult {
|
|
420
|
+
doc: SelectionDoc;
|
|
421
|
+
coherence: CoherenceCheck;
|
|
422
|
+
warnings: string[];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function resolveIntent(input: DevelopmentIntentInput): DevelopmentIntent {
|
|
426
|
+
return typeof input === "string" && (MENU_KEYS as string[]).includes(input)
|
|
427
|
+
? intentFromAverageKey(input as AverageKey)
|
|
428
|
+
: (input as DevelopmentIntent);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function selectionsToDoc(
|
|
432
|
+
selections: LdfSelections,
|
|
433
|
+
options: SelectionsToDocOptions,
|
|
434
|
+
): SelectionsToDocResult {
|
|
435
|
+
const agesMonths = options.triangleDoc.triangle.agesMonths;
|
|
436
|
+
const nColumns = agesMonths.length - 1;
|
|
437
|
+
if (selections.selected.length !== nColumns) {
|
|
438
|
+
throw new ReservingError(
|
|
439
|
+
"SELECTION_SHAPE",
|
|
440
|
+
`Expected ${nColumns} LDF selections (one per development interval), got ${selections.selected.length}`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (options.intents.length !== nColumns) {
|
|
444
|
+
throw new ReservingError(
|
|
445
|
+
"BAD_INTERCHANGE",
|
|
446
|
+
`Expected ${nColumns} intents (one per development column), got ${options.intents.length}`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const development: DevelopmentSelection[] = [];
|
|
451
|
+
for (let j = 0; j < nColumns; j++) {
|
|
452
|
+
const value = selections.selected[j];
|
|
453
|
+
if (value === null || value === undefined) continue;
|
|
454
|
+
const intentInput = options.intents[j];
|
|
455
|
+
if (intentInput === null || intentInput === undefined) {
|
|
456
|
+
throw new ReservingError(
|
|
457
|
+
"BAD_INTERCHANGE",
|
|
458
|
+
`Column ${agesMonths[j]}→${agesMonths[j + 1]} months has a selected factor but no intent; ` +
|
|
459
|
+
'hand-picked values need { kind: "judgmental", rationale: "..." }',
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
development.push({
|
|
463
|
+
fromAgeMonths: agesMonths[j]!,
|
|
464
|
+
toAgeMonths: agesMonths[j + 1]!,
|
|
465
|
+
value,
|
|
466
|
+
intent: resolveIntent(intentInput),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const body: SelectionBody = {
|
|
471
|
+
appliesTo: {
|
|
472
|
+
measure: options.triangleDoc.triangle.measure,
|
|
473
|
+
triangleIntegrity: options.triangleDoc.integrity,
|
|
474
|
+
},
|
|
475
|
+
development,
|
|
476
|
+
};
|
|
477
|
+
if (options.tailIntent !== undefined) {
|
|
478
|
+
body.tail = { value: selections.tailFactor, intent: options.tailIntent };
|
|
479
|
+
} else if (selections.tailFactor !== 1) {
|
|
480
|
+
throw new ReservingError(
|
|
481
|
+
"BAD_INTERCHANGE",
|
|
482
|
+
`Tail factor ${selections.tailFactor} needs a tailIntent (fitted, or judgmental/external with rationale); ` +
|
|
483
|
+
"only a tail of exactly 1 may be omitted",
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const coherence = checkSelectionCoherence(body, options.triangleDoc, {
|
|
488
|
+
strictness: options.strictness ?? "refuse",
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const doc = stampIntegrity<SelectionDoc>({
|
|
492
|
+
interchangeVersion: INTERCHANGE_SPEC_VERSION,
|
|
493
|
+
kind: "selection",
|
|
494
|
+
generator: options.generator ?? DEFAULT_GENERATOR,
|
|
495
|
+
createdAt: options.createdAt,
|
|
496
|
+
extensions: options.extensions ?? {},
|
|
497
|
+
selection: body,
|
|
498
|
+
});
|
|
499
|
+
const checked = selectionDocSchema.safeParse(doc);
|
|
500
|
+
if (!checked.success) {
|
|
501
|
+
throw new ReservingError(
|
|
502
|
+
"BAD_INTERCHANGE",
|
|
503
|
+
`selectionsToDoc produced an invalid document: ${checked.error.issues
|
|
504
|
+
.map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
|
|
505
|
+
.join("; ")}`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
return { doc: checked.data, coherence, warnings: coherence.warnings };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export interface DocToSelectionsOptions {
|
|
512
|
+
/** The triangle document the selection claims to apply to. */
|
|
513
|
+
triangleDoc: TriangleDoc;
|
|
514
|
+
/** Importer-side coherence strictness. Default "warn"; promotion gates
|
|
515
|
+
* and other refuse-mode consumers pass "refuse". */
|
|
516
|
+
strictness?: "warn" | "refuse";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export interface DocToSelectionsResult {
|
|
520
|
+
selections: LdfSelections;
|
|
521
|
+
/** Per column: the standard-menu key that replays the intent exactly,
|
|
522
|
+
* or null (value-only on this shore / no selection). */
|
|
523
|
+
averageKeys: (AverageKey | null)[];
|
|
524
|
+
coherence: CoherenceCheck;
|
|
525
|
+
warnings: string[];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function docToSelections(
|
|
529
|
+
doc: unknown,
|
|
530
|
+
options: DocToSelectionsOptions,
|
|
531
|
+
): DocToSelectionsResult {
|
|
532
|
+
const parsed = selectionDocSchema.safeParse(doc);
|
|
533
|
+
if (!parsed.success) {
|
|
534
|
+
throw new ReservingError(
|
|
535
|
+
"BAD_INTERCHANGE",
|
|
536
|
+
`Not a valid SelectionDoc: ${parsed.error.issues
|
|
537
|
+
.map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
|
|
538
|
+
.join("; ")}`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const body = parsed.data.selection;
|
|
542
|
+
const agesMonths = options.triangleDoc.triangle.agesMonths;
|
|
543
|
+
const cadence = options.triangleDoc.triangle.originLengthMonths;
|
|
544
|
+
|
|
545
|
+
const coherence = checkSelectionCoherence(body, options.triangleDoc, {
|
|
546
|
+
strictness: options.strictness ?? "warn",
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const columns = mapEntriesToColumns(body.development, agesMonths);
|
|
550
|
+
const selected: (number | null)[] = new Array(agesMonths.length - 1).fill(null);
|
|
551
|
+
const averageKeys: (AverageKey | null)[] = new Array(agesMonths.length - 1).fill(null);
|
|
552
|
+
body.development.forEach((entry, idx) => {
|
|
553
|
+
selected[columns[idx]!] = entry.value;
|
|
554
|
+
averageKeys[columns[idx]!] = isValueAuthoritative(entry.intent.kind)
|
|
555
|
+
? null
|
|
556
|
+
: averageKeyForIntent(entry.intent, cadence);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
selections: { selected, tailFactor: body.tail?.value ?? 1 },
|
|
561
|
+
averageKeys,
|
|
562
|
+
coherence,
|
|
563
|
+
warnings: coherence.warnings,
|
|
564
|
+
};
|
|
565
|
+
}
|