@actuarial-ts/compliance 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 +19 -9
- package/dist/bundle.d.ts +1 -1
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +163 -21
- package/dist/bundle.js.map +1 -1
- package/dist/diagnosticArtifactStream.d.ts +30 -0
- package/dist/diagnosticArtifactStream.d.ts.map +1 -0
- package/dist/diagnosticArtifactStream.js +128 -0
- package/dist/diagnosticArtifactStream.js.map +1 -0
- package/dist/diagnosticByteSource.d.ts +10 -0
- package/dist/diagnosticByteSource.d.ts.map +1 -0
- package/dist/diagnosticByteSource.js +70 -0
- package/dist/diagnosticByteSource.js.map +1 -0
- package/dist/diagnosticByteView.d.ts +3 -0
- package/dist/diagnosticByteView.d.ts.map +1 -0
- package/dist/diagnosticByteView.js +27 -0
- package/dist/diagnosticByteView.js.map +1 -0
- package/dist/diagnosticCompactRun.d.ts +43 -0
- package/dist/diagnosticCompactRun.d.ts.map +1 -0
- package/dist/diagnosticCompactRun.js +253 -0
- package/dist/diagnosticCompactRun.js.map +1 -0
- package/dist/diagnosticReplayFrames.d.ts +10 -0
- package/dist/diagnosticReplayFrames.d.ts.map +1 -0
- package/dist/diagnosticReplayFrames.js +99 -0
- package/dist/diagnosticReplayFrames.js.map +1 -0
- package/dist/diagnosticReplayProtocol.d.ts +17 -0
- package/dist/diagnosticReplayProtocol.d.ts.map +1 -0
- package/dist/diagnosticReplayProtocol.js +98 -0
- package/dist/diagnosticReplayProtocol.js.map +1 -0
- package/dist/diagnosticReplayReader.d.ts +41 -0
- package/dist/diagnosticReplayReader.d.ts.map +1 -0
- package/dist/diagnosticReplayReader.js +346 -0
- package/dist/diagnosticReplayReader.js.map +1 -0
- package/dist/diagnosticReplayValue.d.ts +34 -0
- package/dist/diagnosticReplayValue.d.ts.map +1 -0
- package/dist/diagnosticReplayValue.js +269 -0
- package/dist/diagnosticReplayValue.js.map +1 -0
- package/dist/diagnosticReplayWriter.d.ts +25 -0
- package/dist/diagnosticReplayWriter.d.ts.map +1 -0
- package/dist/diagnosticReplayWriter.js +148 -0
- package/dist/diagnosticReplayWriter.js.map +1 -0
- package/dist/diagnosticRun.d.ts +52 -29
- package/dist/diagnosticRun.d.ts.map +1 -1
- package/dist/diagnosticRun.js +564 -174
- package/dist/diagnosticRun.js.map +1 -1
- package/dist/diagnosticRunEvidence.d.ts +18 -0
- package/dist/diagnosticRunEvidence.d.ts.map +1 -0
- package/dist/diagnosticRunEvidence.js +195 -0
- package/dist/diagnosticRunEvidence.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +47 -6
- package/dist/ledger.js.map +1 -1
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +27 -3
- package/dist/metadata.js.map +1 -1
- package/dist/sha256Stream.d.ts +6 -0
- package/dist/sha256Stream.d.ts.map +1 -0
- package/dist/sha256Stream.js +103 -0
- package/dist/sha256Stream.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -5
- package/src/bundle.ts +272 -32
- package/src/diagnosticArtifactStream.ts +148 -0
- package/src/diagnosticByteSource.ts +82 -0
- package/src/diagnosticByteView.ts +27 -0
- package/src/diagnosticCompactRun.ts +462 -0
- package/src/diagnosticReplayFrames.ts +99 -0
- package/src/diagnosticReplayProtocol.ts +99 -0
- package/src/diagnosticReplayReader.ts +428 -0
- package/src/diagnosticReplayValue.ts +269 -0
- package/src/diagnosticReplayWriter.ts +190 -0
- package/src/diagnosticRun.ts +1030 -93
- package/src/diagnosticRunEvidence.ts +391 -0
- package/src/index.ts +10 -0
- package/src/ledger.ts +59 -8
- package/src/metadata.ts +48 -7
- package/src/sha256Stream.ts +108 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import type { CompactPreparedDiagnosticData } from "@actuarial-ts/core";
|
|
2
|
+
import type { CompactDiagnosticReviewReceipt } from "@actuarial-ts/data";
|
|
3
|
+
import type {
|
|
4
|
+
DiagnosticArtifactDigest,
|
|
5
|
+
DiagnosticPreparationLineage,
|
|
6
|
+
} from "./diagnosticRun.js";
|
|
7
|
+
import { ComplianceError } from "./errors.js";
|
|
8
|
+
|
|
9
|
+
/** Shared content validation; callers separately authenticate their run owner. */
|
|
10
|
+
export interface DiagnosticArtifactGraphContent {
|
|
11
|
+
readonly prepared: Pick<
|
|
12
|
+
CompactPreparedDiagnosticData,
|
|
13
|
+
"inputAudit" | "definition"
|
|
14
|
+
>;
|
|
15
|
+
readonly review: Pick<CompactDiagnosticReviewReceipt, "evidence">;
|
|
16
|
+
readonly datasetArtifactId: string | null;
|
|
17
|
+
readonly gate: { readonly rationaleRef: string | null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const token = (value: string, path: string) => {
|
|
21
|
+
if (
|
|
22
|
+
value.length === 0 ||
|
|
23
|
+
/^[\t-\r ]|[\t-\r ]$/.test(value) ||
|
|
24
|
+
value.includes("\0")
|
|
25
|
+
)
|
|
26
|
+
throw new ComplianceError(
|
|
27
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
28
|
+
`${path} must be a nonempty token`,
|
|
29
|
+
path,
|
|
30
|
+
);
|
|
31
|
+
for (let index = 0; index < value.length; index++) {
|
|
32
|
+
const unit = value.charCodeAt(index);
|
|
33
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
34
|
+
const next = value.charCodeAt(index + 1);
|
|
35
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
36
|
+
throw new ComplianceError(
|
|
37
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
38
|
+
`${path} contains malformed Unicode`,
|
|
39
|
+
path,
|
|
40
|
+
);
|
|
41
|
+
index++;
|
|
42
|
+
} else if (unit >= 0xdc00 && unit <= 0xdfff)
|
|
43
|
+
throw new ComplianceError(
|
|
44
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
45
|
+
`${path} contains malformed Unicode`,
|
|
46
|
+
path,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
export function plain(value: unknown): value is Record<string, unknown> {
|
|
51
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
52
|
+
return false;
|
|
53
|
+
const prototype = Object.getPrototypeOf(value);
|
|
54
|
+
return prototype === Object.prototype || prototype === null;
|
|
55
|
+
}
|
|
56
|
+
export function exactKeys(
|
|
57
|
+
value: Record<string, unknown>,
|
|
58
|
+
allowed: readonly string[],
|
|
59
|
+
path: string,
|
|
60
|
+
): void {
|
|
61
|
+
for (const key of Object.keys(value))
|
|
62
|
+
if (!allowed.includes(key))
|
|
63
|
+
throw new ComplianceError(
|
|
64
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
65
|
+
`Unknown field ${key}`,
|
|
66
|
+
`${path}.${key}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
export function snapshotLineage(
|
|
70
|
+
lineage: unknown,
|
|
71
|
+
): readonly DiagnosticPreparationLineage[] {
|
|
72
|
+
if (!Array.isArray(lineage))
|
|
73
|
+
throw new ComplianceError(
|
|
74
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
75
|
+
"$.preparationLineage must be an array",
|
|
76
|
+
"$.preparationLineage",
|
|
77
|
+
);
|
|
78
|
+
return lineage
|
|
79
|
+
.map((raw, index) => {
|
|
80
|
+
const path = `$.preparationLineage[${index}]`;
|
|
81
|
+
if (!plain(raw))
|
|
82
|
+
throw new ComplianceError(
|
|
83
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
84
|
+
"Lineage edge must be a plain object",
|
|
85
|
+
path,
|
|
86
|
+
);
|
|
87
|
+
exactKeys(
|
|
88
|
+
raw,
|
|
89
|
+
["outputArtifactId", "inputArtifactIds", "transformationArtifactIds"],
|
|
90
|
+
path,
|
|
91
|
+
);
|
|
92
|
+
if (typeof raw.outputArtifactId !== "string")
|
|
93
|
+
throw new ComplianceError(
|
|
94
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
95
|
+
"Lineage output must be a string",
|
|
96
|
+
`${path}.outputArtifactId`,
|
|
97
|
+
);
|
|
98
|
+
token(raw.outputArtifactId, `${path}.outputArtifactId`);
|
|
99
|
+
const readIds = (value: unknown, key: string) => {
|
|
100
|
+
if (!Array.isArray(value))
|
|
101
|
+
throw new ComplianceError(
|
|
102
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
103
|
+
`Lineage ${key} must be an array`,
|
|
104
|
+
`${path}.${key}`,
|
|
105
|
+
);
|
|
106
|
+
const result = value.map((id, inputIndex) => {
|
|
107
|
+
if (typeof id !== "string")
|
|
108
|
+
throw new ComplianceError(
|
|
109
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
110
|
+
"Lineage artifact id must be a string",
|
|
111
|
+
`${path}.${key}[${inputIndex}]`,
|
|
112
|
+
);
|
|
113
|
+
token(id, `${path}.${key}[${inputIndex}]`);
|
|
114
|
+
return id;
|
|
115
|
+
});
|
|
116
|
+
if (new Set(result).size !== result.length)
|
|
117
|
+
throw new ComplianceError(
|
|
118
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
119
|
+
`Lineage ${key} must be unique`,
|
|
120
|
+
`${path}.${key}`,
|
|
121
|
+
);
|
|
122
|
+
return [...result].sort();
|
|
123
|
+
};
|
|
124
|
+
const inputs = readIds(raw.inputArtifactIds, "inputArtifactIds"),
|
|
125
|
+
transformations = readIds(
|
|
126
|
+
raw.transformationArtifactIds,
|
|
127
|
+
"transformationArtifactIds",
|
|
128
|
+
);
|
|
129
|
+
if (inputs.length + transformations.length === 0)
|
|
130
|
+
throw new ComplianceError(
|
|
131
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
132
|
+
"Lineage edge must have at least one upstream artifact",
|
|
133
|
+
path,
|
|
134
|
+
);
|
|
135
|
+
return {
|
|
136
|
+
outputArtifactId: raw.outputArtifactId,
|
|
137
|
+
inputArtifactIds: inputs,
|
|
138
|
+
transformationArtifactIds: transformations,
|
|
139
|
+
};
|
|
140
|
+
})
|
|
141
|
+
.sort((a, b) =>
|
|
142
|
+
a.outputArtifactId < b.outputArtifactId
|
|
143
|
+
? -1
|
|
144
|
+
: a.outputArtifactId > b.outputArtifactId
|
|
145
|
+
? 1
|
|
146
|
+
: 0,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function validateArtifactGraph(
|
|
151
|
+
run: DiagnosticArtifactGraphContent,
|
|
152
|
+
inputArtifacts: readonly Pick<
|
|
153
|
+
DiagnosticArtifactDigest,
|
|
154
|
+
"id" | "scope" | "assurance"
|
|
155
|
+
>[],
|
|
156
|
+
preparationArtifacts: readonly Pick<
|
|
157
|
+
DiagnosticArtifactDigest,
|
|
158
|
+
"id" | "scope" | "assurance"
|
|
159
|
+
>[],
|
|
160
|
+
lineage: readonly DiagnosticPreparationLineage[],
|
|
161
|
+
): void {
|
|
162
|
+
const byId = new Map<
|
|
163
|
+
string,
|
|
164
|
+
Pick<DiagnosticArtifactDigest, "id" | "scope" | "assurance">
|
|
165
|
+
>();
|
|
166
|
+
for (const [index, item] of [
|
|
167
|
+
...inputArtifacts,
|
|
168
|
+
...preparationArtifacts,
|
|
169
|
+
].entries()) {
|
|
170
|
+
if (byId.has(item.id))
|
|
171
|
+
throw new ComplianceError(
|
|
172
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
173
|
+
`Duplicate artifact ID ${item.id}`,
|
|
174
|
+
`$.${index < inputArtifacts.length ? "inputArtifacts" : "preparationArtifacts"}[${index < inputArtifacts.length ? index : index - inputArtifacts.length}].id`,
|
|
175
|
+
);
|
|
176
|
+
byId.set(item.id, item);
|
|
177
|
+
}
|
|
178
|
+
const referenced = new Set<string>();
|
|
179
|
+
const requireArtifact = (
|
|
180
|
+
id: string,
|
|
181
|
+
scope: "input" | "preparation",
|
|
182
|
+
path: string,
|
|
183
|
+
) => {
|
|
184
|
+
const artifact = byId.get(id);
|
|
185
|
+
if (!artifact)
|
|
186
|
+
throw new ComplianceError(
|
|
187
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
188
|
+
`Artifact reference ${id} is unresolved`,
|
|
189
|
+
path,
|
|
190
|
+
);
|
|
191
|
+
if (artifact.scope !== scope)
|
|
192
|
+
throw new ComplianceError(
|
|
193
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
194
|
+
`Artifact ${id} must have ${scope} scope`,
|
|
195
|
+
path,
|
|
196
|
+
);
|
|
197
|
+
referenced.add(id);
|
|
198
|
+
};
|
|
199
|
+
let unsourced = false;
|
|
200
|
+
for (const [index, item] of run.prepared.inputAudit.entries()) {
|
|
201
|
+
const source = item.record.source;
|
|
202
|
+
if (source)
|
|
203
|
+
requireArtifact(
|
|
204
|
+
source.artifactId,
|
|
205
|
+
"input",
|
|
206
|
+
`$.completedRun.prepared.inputAudit[${index}].record.source.artifactId`,
|
|
207
|
+
);
|
|
208
|
+
else unsourced = true;
|
|
209
|
+
}
|
|
210
|
+
const evidence = run.review.evidence;
|
|
211
|
+
if (evidence) {
|
|
212
|
+
for (const [index, item] of evidence.groupingAssignments.entries()) {
|
|
213
|
+
if (item.source)
|
|
214
|
+
requireArtifact(
|
|
215
|
+
item.source.artifactId,
|
|
216
|
+
"input",
|
|
217
|
+
`$.completedRun.review.evidence.groupingAssignments[${index}].source.artifactId`,
|
|
218
|
+
);
|
|
219
|
+
else unsourced = true;
|
|
220
|
+
}
|
|
221
|
+
for (const [index, item] of evidence.cachedFormulas.entries()) {
|
|
222
|
+
if (item.source)
|
|
223
|
+
requireArtifact(
|
|
224
|
+
item.source.artifactId,
|
|
225
|
+
"input",
|
|
226
|
+
`$.completedRun.review.evidence.cachedFormulas[${index}].source.artifactId`,
|
|
227
|
+
);
|
|
228
|
+
else unsourced = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (unsourced) {
|
|
232
|
+
if (run.datasetArtifactId === null)
|
|
233
|
+
throw new ComplianceError(
|
|
234
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
235
|
+
"Unsourced diagnostic input requires datasetArtifactId",
|
|
236
|
+
"$.completedRun.datasetArtifactId",
|
|
237
|
+
);
|
|
238
|
+
const fallback = byId.get(run.datasetArtifactId);
|
|
239
|
+
if (
|
|
240
|
+
!fallback ||
|
|
241
|
+
fallback.scope !== "input" ||
|
|
242
|
+
fallback.assurance !== "sdk-computed"
|
|
243
|
+
)
|
|
244
|
+
throw new ComplianceError(
|
|
245
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
246
|
+
"datasetArtifactId must resolve to SDK-computed input evidence",
|
|
247
|
+
"$.completedRun.datasetArtifactId",
|
|
248
|
+
);
|
|
249
|
+
referenced.add(run.datasetArtifactId);
|
|
250
|
+
} else if (run.datasetArtifactId !== null) {
|
|
251
|
+
requireArtifact(
|
|
252
|
+
run.datasetArtifactId,
|
|
253
|
+
"input",
|
|
254
|
+
"$.completedRun.datasetArtifactId",
|
|
255
|
+
);
|
|
256
|
+
if (byId.get(run.datasetArtifactId)!.assurance !== "sdk-computed")
|
|
257
|
+
throw new ComplianceError(
|
|
258
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
259
|
+
"datasetArtifactId must resolve to SDK-computed input evidence",
|
|
260
|
+
"$.completedRun.datasetArtifactId",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
for (const [
|
|
264
|
+
basisIndex,
|
|
265
|
+
basis,
|
|
266
|
+
] of run.prepared.definition.definition.amountBases.entries())
|
|
267
|
+
for (const [componentIndex, component] of basis.components.entries())
|
|
268
|
+
if (
|
|
269
|
+
component.limitation.kind !== "unlimited" &&
|
|
270
|
+
component.limitation.kind !== "unknown" &&
|
|
271
|
+
component.limitation.derivation.kind === "external"
|
|
272
|
+
)
|
|
273
|
+
requireArtifact(
|
|
274
|
+
component.limitation.derivation.transformationRef,
|
|
275
|
+
"preparation",
|
|
276
|
+
`$.definition.amountBases[${basisIndex}].components[${componentIndex}].limitation.derivation.transformationRef`,
|
|
277
|
+
);
|
|
278
|
+
for (const [
|
|
279
|
+
ruleIndex,
|
|
280
|
+
rule,
|
|
281
|
+
] of run.prepared.definition.definition.reviewRules.entries())
|
|
282
|
+
if (
|
|
283
|
+
rule.kind === "layer-order" &&
|
|
284
|
+
rule.comparability.kind === "caller-asserted"
|
|
285
|
+
)
|
|
286
|
+
requireArtifact(
|
|
287
|
+
rule.comparability.rationaleArtifactId,
|
|
288
|
+
"preparation",
|
|
289
|
+
`$.definition.reviewRules[${ruleIndex}].comparability.rationaleArtifactId`,
|
|
290
|
+
);
|
|
291
|
+
if (run.gate.rationaleRef !== null)
|
|
292
|
+
requireArtifact(
|
|
293
|
+
run.gate.rationaleRef,
|
|
294
|
+
"preparation",
|
|
295
|
+
"$.completedRun.gate.rationaleRef",
|
|
296
|
+
);
|
|
297
|
+
const edges = new Map<string, readonly string[]>();
|
|
298
|
+
for (const [index, edge] of lineage.entries()) {
|
|
299
|
+
const path = `$.preparationLineage[${index}]`;
|
|
300
|
+
if (edges.has(edge.outputArtifactId))
|
|
301
|
+
throw new ComplianceError(
|
|
302
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
303
|
+
"An artifact may have only one producing lineage edge",
|
|
304
|
+
`${path}.outputArtifactId`,
|
|
305
|
+
);
|
|
306
|
+
const downstream = byId.get(edge.outputArtifactId);
|
|
307
|
+
if (!downstream)
|
|
308
|
+
throw new ComplianceError(
|
|
309
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
310
|
+
`Lineage artifact ${edge.outputArtifactId} is unresolved`,
|
|
311
|
+
`${path}.outputArtifactId`,
|
|
312
|
+
);
|
|
313
|
+
if (downstream.scope !== "input")
|
|
314
|
+
throw new ComplianceError(
|
|
315
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
316
|
+
"Lineage output artifact must have input scope",
|
|
317
|
+
`${path}.outputArtifactId`,
|
|
318
|
+
);
|
|
319
|
+
for (const [inputIndex, id] of edge.inputArtifactIds.entries()) {
|
|
320
|
+
if (id === edge.outputArtifactId)
|
|
321
|
+
throw new ComplianceError(
|
|
322
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
323
|
+
"Lineage may not reference itself",
|
|
324
|
+
`${path}.inputArtifactIds[${inputIndex}]`,
|
|
325
|
+
);
|
|
326
|
+
const artifact = byId.get(id);
|
|
327
|
+
if (!artifact || artifact.scope !== "input")
|
|
328
|
+
throw new ComplianceError(
|
|
329
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
330
|
+
`Lineage input ${id} must resolve to input evidence`,
|
|
331
|
+
`${path}.inputArtifactIds[${inputIndex}]`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
for (const [
|
|
335
|
+
transformIndex,
|
|
336
|
+
id,
|
|
337
|
+
] of edge.transformationArtifactIds.entries()) {
|
|
338
|
+
const artifact = byId.get(id);
|
|
339
|
+
if (!artifact || artifact.scope !== "preparation")
|
|
340
|
+
throw new ComplianceError(
|
|
341
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
342
|
+
`Lineage transformation ${id} must resolve to preparation evidence`,
|
|
343
|
+
`${path}.transformationArtifactIds[${transformIndex}]`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
edges.set(edge.outputArtifactId, [
|
|
347
|
+
...edge.inputArtifactIds,
|
|
348
|
+
...edge.transformationArtifactIds,
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
351
|
+
const visiting = new Set<string>(),
|
|
352
|
+
visited = new Set<string>();
|
|
353
|
+
for (const root of [...referenced]) {
|
|
354
|
+
const stack = [{ id: root, exit: false }];
|
|
355
|
+
while (stack.length > 0) {
|
|
356
|
+
const { id, exit } = stack.pop()!;
|
|
357
|
+
if (exit) {
|
|
358
|
+
visiting.delete(id);
|
|
359
|
+
visited.add(id);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (visiting.has(id))
|
|
363
|
+
throw new ComplianceError(
|
|
364
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
365
|
+
"Artifact lineage contains a cycle",
|
|
366
|
+
"$.preparationLineage",
|
|
367
|
+
);
|
|
368
|
+
if (visited.has(id)) continue;
|
|
369
|
+
visiting.add(id);
|
|
370
|
+
stack.push({ id, exit: true });
|
|
371
|
+
for (const upstream of [...(edges.get(id) ?? [])].reverse()) {
|
|
372
|
+
referenced.add(upstream);
|
|
373
|
+
stack.push({ id: upstream, exit: false });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const [index, artifact] of inputArtifacts.entries())
|
|
378
|
+
if (!referenced.has(artifact.id))
|
|
379
|
+
throw new ComplianceError(
|
|
380
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
381
|
+
`Artifact ${artifact.id} is orphaned`,
|
|
382
|
+
`$.inputArtifacts[${index}].id`,
|
|
383
|
+
);
|
|
384
|
+
for (const [index, artifact] of preparationArtifacts.entries())
|
|
385
|
+
if (!referenced.has(artifact.id))
|
|
386
|
+
throw new ComplianceError(
|
|
387
|
+
"BAD_DIAGNOSTIC_RUN",
|
|
388
|
+
`Artifact ${artifact.id} is orphaned`,
|
|
389
|
+
`$.preparationArtifacts[${index}].id`,
|
|
390
|
+
);
|
|
391
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,5 +5,15 @@ export * from "./ledger.js";
|
|
|
5
5
|
export * from "./modelCards.js";
|
|
6
6
|
export * from "./disclosure.js";
|
|
7
7
|
export * from "./diagnosticRun.js";
|
|
8
|
+
export * from "./diagnosticArtifactStream.js";
|
|
9
|
+
export {
|
|
10
|
+
createCompactDiagnosticRunIdentity,
|
|
11
|
+
assertVerifiedCompactDiagnosticRunProvenance,
|
|
12
|
+
type CompactDiagnosticArtifactEvidence,
|
|
13
|
+
type CreateCompactDiagnosticRunIdentityInput,
|
|
14
|
+
type VerifiedCompactDiagnosticRunProvenance,
|
|
15
|
+
} from "./diagnosticCompactRun.js";
|
|
16
|
+
export * from "./diagnosticReplayWriter.js";
|
|
17
|
+
export * from "./diagnosticReplayReader.js";
|
|
8
18
|
export * from "./bundle.js";
|
|
9
19
|
export * from "./ave.js";
|
package/src/ledger.ts
CHANGED
|
@@ -30,7 +30,13 @@ import { canonicalJson, ComplianceError } from "./bundle.js";
|
|
|
30
30
|
export type AssumptionActor = "default" | "actuary" | "agent";
|
|
31
31
|
|
|
32
32
|
/** JSON-representable assumption value (the ledger stores data, not behavior). */
|
|
33
|
-
export type JsonValue =
|
|
33
|
+
export type JsonValue =
|
|
34
|
+
| string
|
|
35
|
+
| number
|
|
36
|
+
| boolean
|
|
37
|
+
| null
|
|
38
|
+
| JsonValue[]
|
|
39
|
+
| { [key: string]: JsonValue };
|
|
34
40
|
|
|
35
41
|
export interface AssumptionEntry {
|
|
36
42
|
/** 1-based position in the ledger; assigned by recordAssumption, never by the caller. */
|
|
@@ -58,7 +64,31 @@ export interface AssumptionLedger {
|
|
|
58
64
|
|
|
59
65
|
/** An empty, frozen ledger. */
|
|
60
66
|
export function createLedger(): AssumptionLedger {
|
|
61
|
-
return Object.freeze({
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
entries: Object.freeze([]) as readonly AssumptionEntry[],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function snapshotJson(value: JsonValue): JsonValue {
|
|
73
|
+
let cloned: JsonValue;
|
|
74
|
+
try {
|
|
75
|
+
cloned = JSON.parse(canonicalJson(value)) as JsonValue;
|
|
76
|
+
} catch {
|
|
77
|
+
throw new ComplianceError(
|
|
78
|
+
"BAD_BUNDLE",
|
|
79
|
+
"Assumption values must be finite, acyclic JSON data",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const freeze = (item: JsonValue): JsonValue => {
|
|
83
|
+
if (item === null || typeof item !== "object") return item;
|
|
84
|
+
if (Array.isArray(item)) {
|
|
85
|
+
for (const child of item) freeze(child);
|
|
86
|
+
} else {
|
|
87
|
+
for (const child of Object.values(item)) freeze(child);
|
|
88
|
+
}
|
|
89
|
+
return Object.freeze(item) as JsonValue;
|
|
90
|
+
};
|
|
91
|
+
return freeze(cloned);
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
/**
|
|
@@ -67,15 +97,30 @@ export function createLedger(): AssumptionLedger {
|
|
|
67
97
|
* ComplianceError("MISSING_RATIONALE") when actor !== "default" and rationale
|
|
68
98
|
* is missing or blank.
|
|
69
99
|
*/
|
|
70
|
-
export function recordAssumption(
|
|
71
|
-
|
|
100
|
+
export function recordAssumption(
|
|
101
|
+
ledger: AssumptionLedger,
|
|
102
|
+
entry: NewAssumptionEntry,
|
|
103
|
+
): AssumptionLedger {
|
|
104
|
+
if (
|
|
105
|
+
entry.actor !== "default" &&
|
|
106
|
+
(entry.rationale === undefined || entry.rationale.trim() === "")
|
|
107
|
+
) {
|
|
72
108
|
throw new ComplianceError(
|
|
73
109
|
"MISSING_RATIONALE",
|
|
74
110
|
`assumption "${entry.field}" set by actor "${entry.actor}" requires a rationale; only actor "default" may omit one`,
|
|
75
111
|
);
|
|
76
112
|
}
|
|
77
|
-
const recorded: AssumptionEntry = Object.freeze({
|
|
78
|
-
|
|
113
|
+
const recorded: AssumptionEntry = Object.freeze({
|
|
114
|
+
...entry,
|
|
115
|
+
value: snapshotJson(entry.value),
|
|
116
|
+
...(entry.previousValue === undefined
|
|
117
|
+
? {}
|
|
118
|
+
: { previousValue: snapshotJson(entry.previousValue) }),
|
|
119
|
+
seq: ledger.entries.length + 1,
|
|
120
|
+
});
|
|
121
|
+
return Object.freeze({
|
|
122
|
+
entries: Object.freeze([...ledger.entries, recorded]),
|
|
123
|
+
});
|
|
79
124
|
}
|
|
80
125
|
|
|
81
126
|
/** Entries that represent judgment (actor !== "default"), in ledger order. */
|
|
@@ -120,7 +165,9 @@ export function changedAssumptions(
|
|
|
120
165
|
const added: string[] = [];
|
|
121
166
|
const removed: string[] = [];
|
|
122
167
|
const changed: AssumptionValueChange[] = [];
|
|
123
|
-
const fields = [
|
|
168
|
+
const fields = [
|
|
169
|
+
...new Set([...priorLatest.keys(), ...currentLatest.keys()]),
|
|
170
|
+
].sort();
|
|
124
171
|
for (const field of fields) {
|
|
125
172
|
const before = priorLatest.get(field);
|
|
126
173
|
const after = currentLatest.get(field);
|
|
@@ -133,7 +180,11 @@ export function changedAssumptions(
|
|
|
133
180
|
continue;
|
|
134
181
|
}
|
|
135
182
|
if (canonicalJson(before.value) !== canonicalJson(after.value)) {
|
|
136
|
-
changed.push({
|
|
183
|
+
changed.push({
|
|
184
|
+
field,
|
|
185
|
+
priorValue: before.value,
|
|
186
|
+
currentValue: after.value,
|
|
187
|
+
});
|
|
137
188
|
}
|
|
138
189
|
}
|
|
139
190
|
return { added, removed, changed };
|
package/src/metadata.ts
CHANGED
|
@@ -92,7 +92,20 @@ function isIsoDate(value: string): boolean {
|
|
|
92
92
|
const day = Number(match[3]);
|
|
93
93
|
if (month < 1 || month > 12) return false;
|
|
94
94
|
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
95
|
-
const daysInMonth = [
|
|
95
|
+
const daysInMonth = [
|
|
96
|
+
31,
|
|
97
|
+
leap ? 29 : 28,
|
|
98
|
+
31,
|
|
99
|
+
30,
|
|
100
|
+
31,
|
|
101
|
+
30,
|
|
102
|
+
31,
|
|
103
|
+
31,
|
|
104
|
+
30,
|
|
105
|
+
31,
|
|
106
|
+
30,
|
|
107
|
+
31,
|
|
108
|
+
][month - 1]!;
|
|
96
109
|
return day >= 1 && day <= daysInMonth;
|
|
97
110
|
}
|
|
98
111
|
|
|
@@ -100,13 +113,20 @@ function isNonEmptyString(value: unknown): value is string {
|
|
|
100
113
|
return typeof value === "string" && value.trim() !== "";
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
function checkDate(
|
|
116
|
+
function checkDate(
|
|
117
|
+
problems: string[],
|
|
118
|
+
name: string,
|
|
119
|
+
value: unknown,
|
|
120
|
+
required: boolean,
|
|
121
|
+
): void {
|
|
104
122
|
if (value === undefined) {
|
|
105
123
|
if (required) problems.push(`${name} is required (ISO yyyy-mm-dd)`);
|
|
106
124
|
return;
|
|
107
125
|
}
|
|
108
126
|
if (typeof value !== "string" || !isIsoDate(value)) {
|
|
109
|
-
problems.push(
|
|
127
|
+
problems.push(
|
|
128
|
+
`${name} must be a valid ISO date (yyyy-mm-dd); got ${JSON.stringify(value)}`,
|
|
129
|
+
);
|
|
110
130
|
}
|
|
111
131
|
}
|
|
112
132
|
|
|
@@ -123,7 +143,9 @@ export function validateMetadata(metadata: EstimateMetadata): string[] {
|
|
|
123
143
|
|
|
124
144
|
if (metadata.intendedUsers !== undefined) {
|
|
125
145
|
if (!Array.isArray(metadata.intendedUsers)) {
|
|
126
|
-
problems.push(
|
|
146
|
+
problems.push(
|
|
147
|
+
"intendedUsers, when provided, must be an array of non-empty strings",
|
|
148
|
+
);
|
|
127
149
|
} else {
|
|
128
150
|
metadata.intendedUsers.forEach((user, index) => {
|
|
129
151
|
if (!isNonEmptyString(user)) {
|
|
@@ -145,8 +167,15 @@ export function validateMetadata(metadata: EstimateMetadata): string[] {
|
|
|
145
167
|
if (measure.kind === "specified-percentile") {
|
|
146
168
|
const p = measure.percentile;
|
|
147
169
|
if (p === undefined) {
|
|
148
|
-
problems.push(
|
|
149
|
-
|
|
170
|
+
problems.push(
|
|
171
|
+
'intendedMeasure.percentile is required when kind is "specified-percentile"',
|
|
172
|
+
);
|
|
173
|
+
} else if (
|
|
174
|
+
typeof p !== "number" ||
|
|
175
|
+
!Number.isFinite(p) ||
|
|
176
|
+
p <= 0 ||
|
|
177
|
+
p >= 1
|
|
178
|
+
) {
|
|
150
179
|
problems.push(
|
|
151
180
|
`intendedMeasure.percentile must be a fraction strictly between 0 and 1 (e.g. 0.75); got ${JSON.stringify(p)}`,
|
|
152
181
|
);
|
|
@@ -177,8 +206,20 @@ export function validateMetadata(metadata: EstimateMetadata): string[] {
|
|
|
177
206
|
checkDate(problems, "accountingDate", metadata.accountingDate, true);
|
|
178
207
|
checkDate(problems, "valuationDate", metadata.valuationDate, true);
|
|
179
208
|
checkDate(problems, "reviewDate", metadata.reviewDate, false);
|
|
209
|
+
if (
|
|
210
|
+
typeof metadata.reviewDate === "string" &&
|
|
211
|
+
isIsoDate(metadata.reviewDate) &&
|
|
212
|
+
typeof metadata.valuationDate === "string" &&
|
|
213
|
+
isIsoDate(metadata.valuationDate) &&
|
|
214
|
+
metadata.reviewDate < metadata.valuationDate
|
|
215
|
+
) {
|
|
216
|
+
problems.push("reviewDate cannot precede valuationDate");
|
|
217
|
+
}
|
|
180
218
|
|
|
181
|
-
if (
|
|
219
|
+
if (
|
|
220
|
+
metadata.scopeNotes !== undefined &&
|
|
221
|
+
!isNonEmptyString(metadata.scopeNotes)
|
|
222
|
+
) {
|
|
182
223
|
problems.push("scopeNotes, when provided, must be a non-empty string");
|
|
183
224
|
}
|
|
184
225
|
if (metadata.currency !== undefined && !isNonEmptyString(metadata.currency)) {
|