@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,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { envelopeShape } from "../envelope.js";
|
|
3
|
+
import { engineStampSchema, resultAppliesToSchema } from "./result.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CrosscheckReportDoc (spec 3.2 / 5) — the referee's output: engines
|
|
7
|
+
* compared (with versions and profiles), the appliesTo tags matched,
|
|
8
|
+
* requested and effective parameter sets, per-origin and total relative
|
|
9
|
+
* deviations, the tolerance applied, and the verdict.
|
|
10
|
+
*
|
|
11
|
+
* Verdicts:
|
|
12
|
+
* - `agree` / `disagree`: independent recomputations inside/outside the
|
|
13
|
+
* tolerance.
|
|
14
|
+
* - `not-comparable`: the inputs do not describe the same computation
|
|
15
|
+
* (mismatched appliesTo tags, differing convention profiles, differing
|
|
16
|
+
* origin sets, or a failed injection on either shore).
|
|
17
|
+
* - `verified-by-value`: the results match but the selection was
|
|
18
|
+
* value-only, so no independent recomputation occurred — disclosure
|
|
19
|
+
* renders it distinctly so nothing overstates what was checked.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const CROSSCHECK_VERDICTS = [
|
|
23
|
+
"agree",
|
|
24
|
+
"disagree",
|
|
25
|
+
"not-comparable",
|
|
26
|
+
"verified-by-value",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
export type CrosscheckVerdict = (typeof CROSSCHECK_VERDICTS)[number];
|
|
30
|
+
|
|
31
|
+
/** Relative deviations per metric; null = not compared (metric absent on a side). */
|
|
32
|
+
export const deviationCellSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
ultimate: z.number().nullable(),
|
|
35
|
+
unpaid: z.number().nullable(),
|
|
36
|
+
standardError: z.number().nullable(),
|
|
37
|
+
})
|
|
38
|
+
.passthrough();
|
|
39
|
+
|
|
40
|
+
export const originDeviationSchema = deviationCellSchema
|
|
41
|
+
.extend({ origin: z.string().min(1) })
|
|
42
|
+
.passthrough();
|
|
43
|
+
|
|
44
|
+
const parameterSetSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
requested: z.record(z.unknown()),
|
|
47
|
+
effective: z.record(z.unknown()).nullable(),
|
|
48
|
+
})
|
|
49
|
+
.passthrough();
|
|
50
|
+
|
|
51
|
+
export const crosscheckBodySchema = z
|
|
52
|
+
.object({
|
|
53
|
+
engines: z.object({ a: engineStampSchema, b: engineStampSchema }).passthrough(),
|
|
54
|
+
/** The matched tags; null when the inputs' tags did not match. */
|
|
55
|
+
appliesTo: resultAppliesToSchema.nullable(),
|
|
56
|
+
parameters: z.object({ a: parameterSetSchema, b: parameterSetSchema }).passthrough(),
|
|
57
|
+
tolerance: z
|
|
58
|
+
.object({
|
|
59
|
+
central: z.number().positive(),
|
|
60
|
+
standardError: z.number().positive().nullable(),
|
|
61
|
+
})
|
|
62
|
+
.passthrough(),
|
|
63
|
+
deviations: z
|
|
64
|
+
.object({
|
|
65
|
+
perOrigin: z.array(originDeviationSchema),
|
|
66
|
+
totals: deviationCellSchema,
|
|
67
|
+
})
|
|
68
|
+
.passthrough(),
|
|
69
|
+
verdict: z.enum(CROSSCHECK_VERDICTS),
|
|
70
|
+
warnings: z.array(z.string()),
|
|
71
|
+
})
|
|
72
|
+
.passthrough();
|
|
73
|
+
|
|
74
|
+
export type CrosscheckBody = z.infer<typeof crosscheckBodySchema>;
|
|
75
|
+
|
|
76
|
+
export const crosscheckReportDocSchema = z
|
|
77
|
+
.object({
|
|
78
|
+
...envelopeShape("crosscheck-report"),
|
|
79
|
+
/** Semantic body key per spec rev 2.1: `report` (the head-noun rule). */
|
|
80
|
+
report: crosscheckBodySchema,
|
|
81
|
+
})
|
|
82
|
+
.passthrough();
|
|
83
|
+
|
|
84
|
+
export type CrosscheckReportDoc = z.infer<typeof crosscheckReportDocSchema>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { DocumentKind } from "../envelope.js";
|
|
3
|
+
import { triangleDocSchema } from "./triangle.js";
|
|
4
|
+
import { selectionDocSchema } from "./selection.js";
|
|
5
|
+
import { methodResultDocSchema, stochasticResultDocSchema } from "./result.js";
|
|
6
|
+
import { studyDocSchema } from "./study.js";
|
|
7
|
+
import { bundleDocSchema } from "./bundle.js";
|
|
8
|
+
import { crosscheckReportDocSchema } from "./crosscheck.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Schema publication manifest (spec 3.4). The zod schemas here are the
|
|
12
|
+
* single source of truth; they are mechanically emitted to JSON Schema
|
|
13
|
+
* under `schema/interchange/1.0/` (committed, URL-referenced by the
|
|
14
|
+
* Python/R validators). A vitest regenerates and diffs — drift fails the
|
|
15
|
+
* build; `scripts/emit-schema.ts` rewrites the committed files.
|
|
16
|
+
*
|
|
17
|
+
* `emitJsonSchema` takes the zod→JSON-Schema converter as an argument so
|
|
18
|
+
* `zod-to-json-schema` stays a devDependency (emission is build-time
|
|
19
|
+
* only; the published package depends on core + zod, nothing else).
|
|
20
|
+
*
|
|
21
|
+
* The emitted schemas are the STRUCTURAL contract. TS-side refinements
|
|
22
|
+
* (rationale-required-for-judgmental, medial-trim scoping, grid shape)
|
|
23
|
+
* are enforced by the zod schemas and restated in the spec; non-TS
|
|
24
|
+
* validators implement them as semantic checks beside the JSON Schema.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface SchemaManifestEntry {
|
|
28
|
+
kind: DocumentKind;
|
|
29
|
+
fileName: string;
|
|
30
|
+
schema: z.ZodTypeAny;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const INTERCHANGE_SCHEMA_MANIFEST: readonly SchemaManifestEntry[] = [
|
|
34
|
+
{ kind: "triangle", fileName: "triangle.schema.json", schema: triangleDocSchema },
|
|
35
|
+
{ kind: "selection", fileName: "selection.schema.json", schema: selectionDocSchema },
|
|
36
|
+
{ kind: "method-result", fileName: "method-result.schema.json", schema: methodResultDocSchema },
|
|
37
|
+
{
|
|
38
|
+
kind: "stochastic-result",
|
|
39
|
+
fileName: "stochastic-result.schema.json",
|
|
40
|
+
schema: stochasticResultDocSchema,
|
|
41
|
+
},
|
|
42
|
+
{ kind: "study", fileName: "study.schema.json", schema: studyDocSchema },
|
|
43
|
+
{ kind: "bundle", fileName: "bundle.schema.json", schema: bundleDocSchema },
|
|
44
|
+
{
|
|
45
|
+
kind: "crosscheck-report",
|
|
46
|
+
fileName: "crosscheck-report.schema.json",
|
|
47
|
+
schema: crosscheckReportDocSchema,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Options both the emit script and the drift test must pass to the converter. */
|
|
52
|
+
export function emitOptionsFor(kind: DocumentKind): { name: string; [key: string]: unknown } {
|
|
53
|
+
return { name: kind, target: "jsonSchema7", $refStrategy: "none" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface EmittedSchema {
|
|
57
|
+
kind: DocumentKind;
|
|
58
|
+
fileName: string;
|
|
59
|
+
/** The serialized file content, byte-exact (2-space indent + trailing newline). */
|
|
60
|
+
content: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Emits every document kind's JSON Schema through the supplied converter
|
|
65
|
+
* (pass `zod-to-json-schema`'s default export). Build-time only.
|
|
66
|
+
*/
|
|
67
|
+
export function emitJsonSchema(
|
|
68
|
+
convert: (schema: z.ZodTypeAny, options: { name: string }) => unknown,
|
|
69
|
+
): EmittedSchema[] {
|
|
70
|
+
return INTERCHANGE_SCHEMA_MANIFEST.map(({ kind, fileName, schema }) => ({
|
|
71
|
+
kind,
|
|
72
|
+
fileName,
|
|
73
|
+
content: `${JSON.stringify(convert(schema, emitOptionsFor(kind)), null, 2)}\n`,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { envelopeShape, generatorSchema, integritySchema } from "../envelope.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MethodResultDoc / StochasticResultDoc (spec 3.2).
|
|
6
|
+
*
|
|
7
|
+
* - `appliesTo` links results to their inputs by integrity tag;
|
|
8
|
+
* `selectionIntegrity` is null for runs with no selection document.
|
|
9
|
+
* - Method namespaces: actuarial-ts discriminants unprefixed, `clpy:` for
|
|
10
|
+
* chainladder-python, `rcl:` for R ChainLadder.
|
|
11
|
+
* - `effectiveParameters` records what the engine ACTUALLY did when it
|
|
12
|
+
* deviated from what was requested (R's est.sigma auto-fallback);
|
|
13
|
+
* absent = as requested. The referee downgrades requested≠effective
|
|
14
|
+
* comparisons with a comparability warning.
|
|
15
|
+
* - StochasticResultDoc adds seed/nSims/summary/byOrigin; samples travel
|
|
16
|
+
* only via `samplesRef` in the bulk lane. Cross-engine stochastic
|
|
17
|
+
* comparison is distribution-level only.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const resultAppliesToSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
triangleIntegrity: integritySchema,
|
|
23
|
+
selectionIntegrity: integritySchema.nullable(),
|
|
24
|
+
})
|
|
25
|
+
.passthrough();
|
|
26
|
+
|
|
27
|
+
export type ResultAppliesTo = z.infer<typeof resultAppliesToSchema>;
|
|
28
|
+
|
|
29
|
+
export const engineStampSchema = generatorSchema
|
|
30
|
+
.extend({ conventionProfile: z.string().optional() })
|
|
31
|
+
.passthrough();
|
|
32
|
+
|
|
33
|
+
export type EngineStamp = z.infer<typeof engineStampSchema>;
|
|
34
|
+
|
|
35
|
+
export const methodResultRowSchema = z
|
|
36
|
+
.object({
|
|
37
|
+
origin: z.string().min(1),
|
|
38
|
+
ultimate: z.number().finite(),
|
|
39
|
+
unpaid: z.number().finite(),
|
|
40
|
+
standardError: z.number().finite().optional(),
|
|
41
|
+
})
|
|
42
|
+
.passthrough();
|
|
43
|
+
|
|
44
|
+
export type MethodResultRow = z.infer<typeof methodResultRowSchema>;
|
|
45
|
+
|
|
46
|
+
export const methodResultTotalsSchema = z
|
|
47
|
+
.object({
|
|
48
|
+
ultimate: z.number().finite(),
|
|
49
|
+
unpaid: z.number().finite(),
|
|
50
|
+
standardError: z.number().finite().optional(),
|
|
51
|
+
})
|
|
52
|
+
.passthrough();
|
|
53
|
+
|
|
54
|
+
export type MethodResultTotals = z.infer<typeof methodResultTotalsSchema>;
|
|
55
|
+
|
|
56
|
+
const resultCommonShape = {
|
|
57
|
+
appliesTo: resultAppliesToSchema,
|
|
58
|
+
engine: engineStampSchema,
|
|
59
|
+
method: z.string().min(1),
|
|
60
|
+
parameters: z.record(z.unknown()),
|
|
61
|
+
effectiveParameters: z.record(z.unknown()).optional(),
|
|
62
|
+
warnings: z.array(z.string()).optional(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const methodResultBodySchema = z
|
|
66
|
+
.object({
|
|
67
|
+
...resultCommonShape,
|
|
68
|
+
rows: z.array(methodResultRowSchema),
|
|
69
|
+
totals: methodResultTotalsSchema,
|
|
70
|
+
})
|
|
71
|
+
.passthrough();
|
|
72
|
+
|
|
73
|
+
export type MethodResultBody = z.infer<typeof methodResultBodySchema>;
|
|
74
|
+
|
|
75
|
+
export const methodResultDocSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
...envelopeShape("method-result"),
|
|
78
|
+
result: methodResultBodySchema,
|
|
79
|
+
})
|
|
80
|
+
.passthrough();
|
|
81
|
+
|
|
82
|
+
export type MethodResultDoc = z.infer<typeof methodResultDocSchema>;
|
|
83
|
+
|
|
84
|
+
/** Bulk-lane reference for raw simulation output (spec 3.3). */
|
|
85
|
+
export const samplesRefSchema = z
|
|
86
|
+
.object({ format: z.literal("arrow"), path: z.string().min(1), sha256: z.string().min(1) })
|
|
87
|
+
.passthrough();
|
|
88
|
+
|
|
89
|
+
export const stochasticSummarySchema = z
|
|
90
|
+
.object({
|
|
91
|
+
mean: z.number().finite(),
|
|
92
|
+
sd: z.number().finite(),
|
|
93
|
+
cv: z.number().finite().nullable(),
|
|
94
|
+
/** e.g. { "75": ..., "95": ... } — percentile label → value. */
|
|
95
|
+
percentiles: z.record(z.number().finite()),
|
|
96
|
+
})
|
|
97
|
+
.passthrough();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* How much a consumer may rely on re-running this result.
|
|
101
|
+
*
|
|
102
|
+
* A seed is NOT the same guarantee as reproducibility: it removes one source
|
|
103
|
+
* of variance, but the engine underneath may still be non-deterministic. This
|
|
104
|
+
* field says which promise the document actually carries, so an actuary knows
|
|
105
|
+
* whether they hold a derivation anyone can regenerate or an observation
|
|
106
|
+
* somebody recorded.
|
|
107
|
+
*
|
|
108
|
+
* - `seeded-reproducible` — re-running with this seed reproduces this document
|
|
109
|
+
* byte-for-byte. @actuarial-ts/core's own stochastic layer is this
|
|
110
|
+
* (packages/core/test/odpBootstrap.test.ts pins it), because it uses a seeded
|
|
111
|
+
* PRNG with no ambient randomness.
|
|
112
|
+
* - `witnessed` — the engine is NOT byte-reproducible even under a fixed seed.
|
|
113
|
+
* The document is an integrity-checked record of what that engine produced on
|
|
114
|
+
* that run, not something a reviewer can regenerate. Still legitimate ASOP
|
|
115
|
+
* No. 56 evidence — it just supports an attestation, not a replay.
|
|
116
|
+
*
|
|
117
|
+
* Absent means unstated (documents written before this field existed); treat
|
|
118
|
+
* an absent value as unknown, never as a reproducibility guarantee.
|
|
119
|
+
*
|
|
120
|
+
* Deterministic methods do not carry this field: kind `method-result` already
|
|
121
|
+
* implies reproducibility, and the frozen conformance corpus proves it across
|
|
122
|
+
* three independent shores.
|
|
123
|
+
*/
|
|
124
|
+
export const reproducibilitySchema = z.enum(["seeded-reproducible", "witnessed"]);
|
|
125
|
+
|
|
126
|
+
export type Reproducibility = z.infer<typeof reproducibilitySchema>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The engine's own self-check: it ran the identical seeded request more than
|
|
130
|
+
* once and reports whether the runs agreed. This is what lets a
|
|
131
|
+
* non-reproducible engine be HONEST rather than silently unstable — the
|
|
132
|
+
* instability is measured and disclosed on the document instead of surfacing
|
|
133
|
+
* later as a mysteriously failing test.
|
|
134
|
+
*/
|
|
135
|
+
export const stabilityCheckSchema = z
|
|
136
|
+
.object({
|
|
137
|
+
/** How many independent runs of the identical seeded request were compared (>= 2). */
|
|
138
|
+
repeats: z.number().int().min(2),
|
|
139
|
+
/** True when every repeat produced the identical semantic body. */
|
|
140
|
+
byteIdentical: z.boolean(),
|
|
141
|
+
/**
|
|
142
|
+
* Worst relative deviation observed across repeats on the summary mean,
|
|
143
|
+
* using the cross-shore definition |a-b| / max(|a|,|b|). Null when not
|
|
144
|
+
* computable. Zero with byteIdentical=false means the bodies differed
|
|
145
|
+
* somewhere other than the summary mean.
|
|
146
|
+
*/
|
|
147
|
+
maxRelativeDeviation: z.number().finite().nonnegative().nullable(),
|
|
148
|
+
})
|
|
149
|
+
.passthrough();
|
|
150
|
+
|
|
151
|
+
export type StabilityCheck = z.infer<typeof stabilityCheckSchema>;
|
|
152
|
+
|
|
153
|
+
export const stochasticResultBodySchema = z
|
|
154
|
+
.object({
|
|
155
|
+
...resultCommonShape,
|
|
156
|
+
seed: z.number().int().optional(),
|
|
157
|
+
nSims: z.number().int().positive(),
|
|
158
|
+
/** Which reproducibility promise this document carries; see the schema doc. */
|
|
159
|
+
reproducibility: reproducibilitySchema.optional(),
|
|
160
|
+
/** The engine's repeat-run self-check, when it performed one. */
|
|
161
|
+
stability: stabilityCheckSchema.optional(),
|
|
162
|
+
summary: stochasticSummarySchema,
|
|
163
|
+
byOrigin: z.array(z.object({ origin: z.string().min(1) }).passthrough()),
|
|
164
|
+
samplesRef: samplesRefSchema.optional(),
|
|
165
|
+
/** Point-estimate rows/totals may accompany the distribution summary. */
|
|
166
|
+
rows: z.array(methodResultRowSchema).optional(),
|
|
167
|
+
totals: methodResultTotalsSchema.optional(),
|
|
168
|
+
})
|
|
169
|
+
.passthrough();
|
|
170
|
+
|
|
171
|
+
export type StochasticResultBody = z.infer<typeof stochasticResultBodySchema>;
|
|
172
|
+
|
|
173
|
+
export const stochasticResultDocSchema = z
|
|
174
|
+
.object({
|
|
175
|
+
...envelopeShape("stochastic-result"),
|
|
176
|
+
result: stochasticResultBodySchema,
|
|
177
|
+
})
|
|
178
|
+
.passthrough();
|
|
179
|
+
|
|
180
|
+
export type StochasticResultDoc = z.infer<typeof stochasticResultDocSchema>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { envelopeShape, integritySchema } from "../envelope.js";
|
|
3
|
+
import { measureSchema } from "./triangle.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SelectionDoc (spec 3.2): intent + values, with the coherence rule.
|
|
7
|
+
*
|
|
8
|
+
* - Intent is authoritative for replay and promotion; values are
|
|
9
|
+
* authoritative only for `judgmental`/`external` intents, whose
|
|
10
|
+
* `rationale` is REQUIRED (it carries the judgment's justification).
|
|
11
|
+
* - `windowOriginPeriods` counts ORIGIN PERIODS in the triangle's own
|
|
12
|
+
* cadence; omitted = all periods.
|
|
13
|
+
* - `excludeHigh`/`excludeLow` are medial trims and are valid ONLY with
|
|
14
|
+
* `kind: "medial"` (the spec's normative comment; its illustrative
|
|
15
|
+
* example showing zeros on a volume-weighted intent is not).
|
|
16
|
+
* - The coherence rule itself (recompute computable intents within 1e-9
|
|
17
|
+
* relative, warn|refuse on divergence) is enforced by
|
|
18
|
+
* `convert/selection.ts`, not by this structural schema.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const DEVELOPMENT_INTENT_KINDS = [
|
|
22
|
+
"volume-weighted",
|
|
23
|
+
"simple",
|
|
24
|
+
"regression",
|
|
25
|
+
"geometric",
|
|
26
|
+
"medial",
|
|
27
|
+
"judgmental",
|
|
28
|
+
"external",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export type DevelopmentIntentKind = (typeof DEVELOPMENT_INTENT_KINDS)[number];
|
|
32
|
+
|
|
33
|
+
/** Intent kinds whose `value` is the judgment itself (never recomputed). */
|
|
34
|
+
export const VALUE_AUTHORITATIVE_KINDS = ["judgmental", "external"] as const;
|
|
35
|
+
|
|
36
|
+
export const exclusionSchema = z
|
|
37
|
+
.object({ origin: z.string().min(1), reason: z.string().optional() })
|
|
38
|
+
.passthrough();
|
|
39
|
+
|
|
40
|
+
function requireRationale(
|
|
41
|
+
intent: { kind: string; rationale?: string | undefined },
|
|
42
|
+
ctx: z.RefinementCtx,
|
|
43
|
+
): void {
|
|
44
|
+
if (
|
|
45
|
+
(VALUE_AUTHORITATIVE_KINDS as readonly string[]).includes(intent.kind) &&
|
|
46
|
+
(intent.rationale === undefined || intent.rationale.trim() === "")
|
|
47
|
+
) {
|
|
48
|
+
ctx.addIssue({
|
|
49
|
+
code: z.ZodIssueCode.custom,
|
|
50
|
+
message: `rationale is required when intent kind is "${intent.kind}"`,
|
|
51
|
+
path: ["rationale"],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const developmentIntentSchema = z
|
|
57
|
+
.object({
|
|
58
|
+
kind: z.enum(DEVELOPMENT_INTENT_KINDS),
|
|
59
|
+
windowOriginPeriods: z.number().int().positive().optional(),
|
|
60
|
+
excludeHigh: z.number().int().nonnegative().optional(),
|
|
61
|
+
excludeLow: z.number().int().nonnegative().optional(),
|
|
62
|
+
exclusions: z.array(exclusionSchema).optional(),
|
|
63
|
+
rationale: z.string().optional(),
|
|
64
|
+
})
|
|
65
|
+
.passthrough()
|
|
66
|
+
.superRefine((intent, ctx) => {
|
|
67
|
+
requireRationale(intent, ctx);
|
|
68
|
+
if (intent.kind !== "medial") {
|
|
69
|
+
for (const field of ["excludeHigh", "excludeLow"] as const) {
|
|
70
|
+
if (intent[field] !== undefined) {
|
|
71
|
+
ctx.addIssue({
|
|
72
|
+
code: z.ZodIssueCode.custom,
|
|
73
|
+
message: `${field} is a medial trim; it is valid only with kind "medial"`,
|
|
74
|
+
path: [field],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export type DevelopmentIntent = z.infer<typeof developmentIntentSchema>;
|
|
82
|
+
|
|
83
|
+
export const developmentSelectionSchema = z
|
|
84
|
+
.object({
|
|
85
|
+
fromAgeMonths: z.number().int().positive(),
|
|
86
|
+
toAgeMonths: z.number().int().positive(),
|
|
87
|
+
value: z.number().finite(),
|
|
88
|
+
intent: developmentIntentSchema,
|
|
89
|
+
})
|
|
90
|
+
.passthrough()
|
|
91
|
+
.superRefine((entry, ctx) => {
|
|
92
|
+
if (entry.toAgeMonths <= entry.fromAgeMonths) {
|
|
93
|
+
ctx.addIssue({
|
|
94
|
+
code: z.ZodIssueCode.custom,
|
|
95
|
+
message: "toAgeMonths must be greater than fromAgeMonths",
|
|
96
|
+
path: ["toAgeMonths"],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export type DevelopmentSelection = z.infer<typeof developmentSelectionSchema>;
|
|
102
|
+
|
|
103
|
+
export const TAIL_FAMILIES = ["exponential-decay", "inverse-power"] as const;
|
|
104
|
+
|
|
105
|
+
export type TailFamily = (typeof TAIL_FAMILIES)[number];
|
|
106
|
+
|
|
107
|
+
export const tailIntentSchema = z
|
|
108
|
+
.discriminatedUnion("kind", [
|
|
109
|
+
z
|
|
110
|
+
.object({
|
|
111
|
+
kind: z.literal("fitted"),
|
|
112
|
+
family: z.enum(TAIL_FAMILIES),
|
|
113
|
+
/** First development age (months) included in the fit; omitted = all. */
|
|
114
|
+
fitFromAgeMonths: z.number().int().positive().optional(),
|
|
115
|
+
/** Informative fit coefficients; the coherence check refits from data. */
|
|
116
|
+
params: z
|
|
117
|
+
.object({ intercept: z.number(), slope: z.number() })
|
|
118
|
+
.passthrough()
|
|
119
|
+
.optional(),
|
|
120
|
+
})
|
|
121
|
+
.passthrough(),
|
|
122
|
+
z
|
|
123
|
+
.object({ kind: z.literal("judgmental"), rationale: z.string().optional() })
|
|
124
|
+
.passthrough(),
|
|
125
|
+
z
|
|
126
|
+
.object({ kind: z.literal("external"), rationale: z.string().optional() })
|
|
127
|
+
.passthrough(),
|
|
128
|
+
])
|
|
129
|
+
.superRefine((intent, ctx) => {
|
|
130
|
+
requireRationale(intent, ctx);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export type TailIntent = z.infer<typeof tailIntentSchema>;
|
|
134
|
+
|
|
135
|
+
export const tailSelectionSchema = z
|
|
136
|
+
.object({ value: z.number().finite(), intent: tailIntentSchema })
|
|
137
|
+
.passthrough();
|
|
138
|
+
|
|
139
|
+
export type TailSelection = z.infer<typeof tailSelectionSchema>;
|
|
140
|
+
|
|
141
|
+
export const selectionAppliesToSchema = z
|
|
142
|
+
.object({ measure: measureSchema, triangleIntegrity: integritySchema })
|
|
143
|
+
.passthrough();
|
|
144
|
+
|
|
145
|
+
export const selectionBodySchema = z
|
|
146
|
+
.object({
|
|
147
|
+
appliesTo: selectionAppliesToSchema,
|
|
148
|
+
development: z.array(developmentSelectionSchema),
|
|
149
|
+
tail: tailSelectionSchema.optional(),
|
|
150
|
+
})
|
|
151
|
+
.passthrough();
|
|
152
|
+
|
|
153
|
+
export type SelectionBody = z.infer<typeof selectionBodySchema>;
|
|
154
|
+
|
|
155
|
+
export const selectionDocSchema = z
|
|
156
|
+
.object({
|
|
157
|
+
...envelopeShape("selection"),
|
|
158
|
+
selection: selectionBodySchema,
|
|
159
|
+
})
|
|
160
|
+
.passthrough();
|
|
161
|
+
|
|
162
|
+
export type SelectionDoc = z.infer<typeof selectionDocSchema>;
|
|
163
|
+
|
|
164
|
+
/** True when every intent in the selection is judgmental/external — i.e., a
|
|
165
|
+
* replay applies values directly and no independent recomputation occurs
|
|
166
|
+
* (the referee's `verified-by-value` case). */
|
|
167
|
+
export function isValueOnlySelection(body: SelectionBody): boolean {
|
|
168
|
+
const devValueOnly = body.development.every((d) =>
|
|
169
|
+
(VALUE_AUTHORITATIVE_KINDS as readonly string[]).includes(d.intent.kind),
|
|
170
|
+
);
|
|
171
|
+
const tailValueOnly =
|
|
172
|
+
body.tail === undefined ||
|
|
173
|
+
(VALUE_AUTHORITATIVE_KINDS as readonly string[]).includes(body.tail.intent.kind);
|
|
174
|
+
return devValueOnly && tailValueOnly;
|
|
175
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { envelopeShape, type GeneratorStamp } from "../envelope.js";
|
|
3
|
+
import { type TriangleDoc, triangleDocSchema } from "./triangle.js";
|
|
4
|
+
import { type SelectionDoc, selectionDocSchema } from "./selection.js";
|
|
5
|
+
import {
|
|
6
|
+
type MethodResultDoc,
|
|
7
|
+
type StochasticResultDoc,
|
|
8
|
+
methodResultDocSchema,
|
|
9
|
+
stochasticResultDocSchema,
|
|
10
|
+
} from "./result.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* StudyDoc (spec 3.2) — the promotion unit: a notebook study packaged for
|
|
14
|
+
* the governed workflow.
|
|
15
|
+
*
|
|
16
|
+
* - `narrative.summary` must be non-empty (adapters refuse an empty
|
|
17
|
+
* summary; the Python `save_study` does the same).
|
|
18
|
+
* - `supportingResults` is OPTIONAL: when absent, promotion Gate 2 verifies
|
|
19
|
+
* coherence + replays with no cross-engine referee step.
|
|
20
|
+
* - `expectations.replayTolerance` is subject to the host ceiling at
|
|
21
|
+
* promotion time (spec 6) — the schema carries it, the host enforces it.
|
|
22
|
+
* - `governance` sits BESIDE the semantic body: reserved, round-tripped
|
|
23
|
+
* opaquely by every adapter, not covered by the integrity tag (which
|
|
24
|
+
* covers the `study` object only, per spec 3.1).
|
|
25
|
+
*
|
|
26
|
+
* The interfaces are written out (rather than z.infer'd) because the
|
|
27
|
+
* composite document types exceed tsc's declaration-emit serialization
|
|
28
|
+
* limit; the schemas are annotated with them, so the two cannot drift
|
|
29
|
+
* without a compile error.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface StudyNarrative {
|
|
33
|
+
analyst?: string;
|
|
34
|
+
sourceRef?: string;
|
|
35
|
+
summary: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StudyExpectations {
|
|
40
|
+
replayTolerance?: number;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StudyBody {
|
|
45
|
+
title: string;
|
|
46
|
+
narrative: StudyNarrative;
|
|
47
|
+
triangles: TriangleDoc[];
|
|
48
|
+
selections: SelectionDoc[];
|
|
49
|
+
supportingResults?: (MethodResultDoc | StochasticResultDoc)[];
|
|
50
|
+
expectations?: StudyExpectations;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StudyDoc {
|
|
55
|
+
interchangeVersion: string;
|
|
56
|
+
kind: "study";
|
|
57
|
+
generator: GeneratorStamp & { [key: string]: unknown };
|
|
58
|
+
createdAt: string;
|
|
59
|
+
extensions?: Record<string, unknown>;
|
|
60
|
+
integrity: string;
|
|
61
|
+
study: StudyBody;
|
|
62
|
+
/** Reserved; round-tripped opaquely by non-TS adapters. */
|
|
63
|
+
governance?: Record<string, unknown>;
|
|
64
|
+
[key: string]: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const studyNarrativeSchema: z.ZodType<StudyNarrative> = z
|
|
68
|
+
.object({
|
|
69
|
+
analyst: z.string().optional(),
|
|
70
|
+
sourceRef: z.string().optional(),
|
|
71
|
+
summary: z.string().min(1),
|
|
72
|
+
})
|
|
73
|
+
.passthrough();
|
|
74
|
+
|
|
75
|
+
export const studyExpectationsSchema: z.ZodType<StudyExpectations> = z
|
|
76
|
+
.object({ replayTolerance: z.number().positive().optional() })
|
|
77
|
+
.passthrough();
|
|
78
|
+
|
|
79
|
+
export const studyBodySchema: z.ZodType<StudyBody> = z
|
|
80
|
+
.object({
|
|
81
|
+
title: z.string().min(1),
|
|
82
|
+
narrative: studyNarrativeSchema,
|
|
83
|
+
triangles: z.array(triangleDocSchema),
|
|
84
|
+
selections: z.array(selectionDocSchema),
|
|
85
|
+
supportingResults: z
|
|
86
|
+
.array(z.union([methodResultDocSchema, stochasticResultDocSchema]))
|
|
87
|
+
.optional(),
|
|
88
|
+
expectations: studyExpectationsSchema.optional(),
|
|
89
|
+
})
|
|
90
|
+
.passthrough();
|
|
91
|
+
|
|
92
|
+
export const studyDocSchema: z.ZodType<StudyDoc> = z
|
|
93
|
+
.object({
|
|
94
|
+
...envelopeShape("study"),
|
|
95
|
+
study: studyBodySchema,
|
|
96
|
+
governance: z.record(z.unknown()).optional(),
|
|
97
|
+
})
|
|
98
|
+
.passthrough();
|