@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.
Files changed (42) hide show
  1. package/README.md +28 -1
  2. package/dist/convert/result.d.ts +4 -4
  3. package/dist/convert/result.js +3 -3
  4. package/dist/convert/result.js.map +1 -1
  5. package/dist/envelope.d.ts +1 -1
  6. package/dist/envelope.js +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/parse.d.ts.map +1 -1
  12. package/dist/parse.js +49 -0
  13. package/dist/parse.js.map +1 -1
  14. package/dist/referee/crosscheck.d.ts +12 -0
  15. package/dist/referee/crosscheck.d.ts.map +1 -1
  16. package/dist/referee/crosscheck.js +109 -9
  17. package/dist/referee/crosscheck.js.map +1 -1
  18. package/dist/referee/crosscheckStochastic.d.ts +24 -0
  19. package/dist/referee/crosscheckStochastic.d.ts.map +1 -0
  20. package/dist/referee/crosscheckStochastic.js +367 -0
  21. package/dist/referee/crosscheckStochastic.js.map +1 -0
  22. package/dist/schemas/result.d.ts +552 -0
  23. package/dist/schemas/result.d.ts.map +1 -1
  24. package/dist/schemas/result.js +52 -0
  25. package/dist/schemas/result.js.map +1 -1
  26. package/package.json +4 -2
  27. package/src/convert/result.ts +212 -0
  28. package/src/convert/selection.ts +565 -0
  29. package/src/convert/triangle.ts +208 -0
  30. package/src/envelope.ts +193 -0
  31. package/src/index.ts +15 -0
  32. package/src/parse.ts +175 -0
  33. package/src/referee/crosscheck.ts +459 -0
  34. package/src/referee/crosscheckStochastic.ts +488 -0
  35. package/src/referee/profiles.ts +113 -0
  36. package/src/schemas/bundle.ts +64 -0
  37. package/src/schemas/crosscheck.ts +84 -0
  38. package/src/schemas/manifest.ts +75 -0
  39. package/src/schemas/result.ts +180 -0
  40. package/src/schemas/selection.ts +175 -0
  41. package/src/schemas/study.ts +98 -0
  42. 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();