@actuarial-ts/data 0.7.1 → 0.8.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 (39) hide show
  1. package/README.md +20 -4
  2. package/dist/customizationContracts.d.ts +2473 -0
  3. package/dist/customizationContracts.d.ts.map +1 -0
  4. package/dist/customizationContracts.js +845 -0
  5. package/dist/customizationContracts.js.map +1 -0
  6. package/dist/customizationExternalState.d.ts +42 -0
  7. package/dist/customizationExternalState.d.ts.map +1 -0
  8. package/dist/customizationExternalState.js +121 -0
  9. package/dist/customizationExternalState.js.map +1 -0
  10. package/dist/customizationHistoryStream.d.ts +26 -0
  11. package/dist/customizationHistoryStream.d.ts.map +1 -0
  12. package/dist/customizationHistoryStream.js +66 -0
  13. package/dist/customizationHistoryStream.js.map +1 -0
  14. package/dist/diagnosticInput.d.ts +33 -0
  15. package/dist/diagnosticInput.d.ts.map +1 -1
  16. package/dist/diagnosticInput.js +71 -1
  17. package/dist/diagnosticInput.js.map +1 -1
  18. package/dist/diagnosticPreparedReview.d.ts.map +1 -1
  19. package/dist/diagnosticPreparedReview.js +38 -0
  20. package/dist/diagnosticPreparedReview.js.map +1 -1
  21. package/dist/historyMapping.d.ts +898 -0
  22. package/dist/historyMapping.d.ts.map +1 -0
  23. package/dist/historyMapping.js +395 -0
  24. package/dist/historyMapping.js.map +1 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/package.json +3 -3
  32. package/src/customizationContracts.ts +963 -0
  33. package/src/customizationExternalState.ts +208 -0
  34. package/src/customizationHistoryStream.ts +90 -0
  35. package/src/diagnosticInput.ts +101 -0
  36. package/src/diagnosticPreparedReview.ts +48 -0
  37. package/src/historyMapping.ts +514 -0
  38. package/src/index.ts +4 -0
  39. package/src/version.ts +1 -1
@@ -0,0 +1,208 @@
1
+ import {
2
+ isDiagnosticToken,
3
+ isWellFormedDiagnosticString,
4
+ snapshotDiagnosticJson,
5
+ type CustomizationResourceLimits,
6
+ type JsonValue,
7
+ } from "@actuarial-ts/core";
8
+ import { parseCustomizationResourceLimits } from "./customizationContracts.js";
9
+
10
+ export type CustomizationExecutionErrorCode =
11
+ | "CANCELLED"
12
+ | "RESOURCE_LIMIT"
13
+ | "EXTERNAL_STATE_FAILURE"
14
+ | "EXTERNAL_STATE_ORDER"
15
+ | "EXTERNAL_STATE_TRUNCATED";
16
+
17
+ export class CustomizationExecutionError extends Error {
18
+ readonly code: CustomizationExecutionErrorCode;
19
+
20
+ constructor(code: CustomizationExecutionErrorCode, message: string) {
21
+ super(message);
22
+ this.name = "CustomizationExecutionError";
23
+ this.code = code;
24
+ Object.freeze(this);
25
+ }
26
+ }
27
+
28
+ export interface CustomizationExternalRecord<T extends JsonValue = JsonValue> {
29
+ readonly id: string;
30
+ readonly partitionKey: string;
31
+ readonly orderKey: string;
32
+ readonly value: T;
33
+ }
34
+
35
+ export interface CustomizationExternalStateSession<T extends JsonValue = JsonValue> {
36
+ /** Store one SDK-owned record. The adapter must not mutate it. */
37
+ readonly append: (record: CustomizationExternalRecord<T>) => void | Promise<void>;
38
+ /** Finish writes before reads begin. */
39
+ readonly seal: () => void | Promise<void>;
40
+ /** Reopen all records exactly once in partitionKey/orderKey/id order. */
41
+ readonly read: () => AsyncIterable<CustomizationExternalRecord<T>>;
42
+ /** Release the run's temporary namespace for every terminal outcome. */
43
+ readonly dispose: (outcome: "completed" | "failed" | "cancelled") => void | Promise<void>;
44
+ }
45
+
46
+ export interface CustomizationExternalStateAdapter {
47
+ readonly open: <T extends JsonValue>(request: {
48
+ readonly runId: string;
49
+ readonly signal?: AbortSignal;
50
+ }) => CustomizationExternalStateSession<T> | Promise<CustomizationExternalStateSession<T>>;
51
+ }
52
+
53
+ export interface ExternalSortCustomizationRecordsInput<T extends JsonValue = JsonValue> {
54
+ readonly runId: string;
55
+ readonly records: Iterable<CustomizationExternalRecord<T>> | AsyncIterable<CustomizationExternalRecord<T>>;
56
+ readonly adapter: CustomizationExternalStateAdapter;
57
+ readonly limits: CustomizationResourceLimits;
58
+ readonly signal?: AbortSignal;
59
+ }
60
+
61
+ const encoder = new TextEncoder();
62
+
63
+ function cancelled(signal: AbortSignal | undefined): void {
64
+ if (signal?.aborted)
65
+ throw new CustomizationExecutionError("CANCELLED", "Customization execution was cancelled");
66
+ }
67
+
68
+ function validateRecord<T extends JsonValue>(
69
+ value: CustomizationExternalRecord<T>,
70
+ maximumRecordBytes: number,
71
+ ): CustomizationExternalRecord<T> {
72
+ if (!isDiagnosticToken(value.id))
73
+ throw new CustomizationExecutionError("EXTERNAL_STATE_FAILURE", "External record id is invalid");
74
+ if (
75
+ !isWellFormedDiagnosticString(value.partitionKey) ||
76
+ !isWellFormedDiagnosticString(value.orderKey)
77
+ )
78
+ throw new CustomizationExecutionError(
79
+ "EXTERNAL_STATE_FAILURE",
80
+ "External record sort keys must be valid strings without U+0000",
81
+ );
82
+ const owned = snapshotDiagnosticJson(value);
83
+ const bytes = encoder.encode(JSON.stringify(owned)).byteLength;
84
+ if (bytes > maximumRecordBytes)
85
+ throw new CustomizationExecutionError(
86
+ "RESOURCE_LIMIT",
87
+ `External record ${value.id} requires ${bytes} encoded bytes, exceeding maximumRecordBytes ${maximumRecordBytes}`,
88
+ );
89
+ return owned;
90
+ }
91
+
92
+ function compareRecords(
93
+ left: CustomizationExternalRecord,
94
+ right: CustomizationExternalRecord,
95
+ ): number {
96
+ for (const key of ["partitionKey", "orderKey", "id"] as const) {
97
+ const result = left[key] === right[key] ? 0 : left[key] < right[key] ? -1 : 1;
98
+ if (result !== 0) return result;
99
+ }
100
+ return 0;
101
+ }
102
+
103
+ /**
104
+ * Validates, owns, externally stages, and deterministically rereads records.
105
+ * The adapter supplies storage mechanics only; it cannot choose actuarial
106
+ * ordering or publish a completed run. Early consumer return is cancellation.
107
+ */
108
+ export async function* externalSortCustomizationRecords<T extends JsonValue>(
109
+ input: ExternalSortCustomizationRecordsInput<T>,
110
+ ): AsyncGenerator<CustomizationExternalRecord<T>> {
111
+ const runId = input.runId;
112
+ const records = input.records;
113
+ const adapter = input.adapter;
114
+ const signal = input.signal;
115
+ let limits: CustomizationResourceLimits;
116
+ try {
117
+ limits = parseCustomizationResourceLimits(input.limits);
118
+ } catch (error) {
119
+ throw new CustomizationExecutionError(
120
+ "RESOURCE_LIMIT",
121
+ error instanceof Error ? error.message : "Invalid customization resource limits",
122
+ );
123
+ }
124
+ if (!isDiagnosticToken(runId))
125
+ throw new CustomizationExecutionError("EXTERNAL_STATE_FAILURE", "runId is invalid");
126
+ cancelled(signal);
127
+
128
+ let session: CustomizationExternalStateSession<T> | undefined;
129
+ let outcome: "completed" | "failed" | "cancelled" = "failed";
130
+ let primary: unknown;
131
+ try {
132
+ session = await adapter.open<T>({
133
+ runId,
134
+ ...(signal === undefined ? {} : { signal }),
135
+ });
136
+ let written = 0;
137
+ for await (const raw of records) {
138
+ cancelled(signal);
139
+ written += 1;
140
+ if (written > limits.maximumInputRecords)
141
+ throw new CustomizationExecutionError(
142
+ "RESOURCE_LIMIT",
143
+ `Input exceeds maximumInputRecords ${limits.maximumInputRecords}`,
144
+ );
145
+ await session.append(validateRecord(raw, limits.maximumRecordBytes));
146
+ }
147
+ cancelled(signal);
148
+ await session.seal();
149
+
150
+ let read = 0;
151
+ let partitionRecords = 0;
152
+ let prior: CustomizationExternalRecord<T> | undefined;
153
+ for await (const raw of session.read()) {
154
+ cancelled(signal);
155
+ const record = validateRecord(raw, limits.maximumRecordBytes);
156
+ if (prior !== undefined && compareRecords(prior, record) > 0)
157
+ throw new CustomizationExecutionError(
158
+ "EXTERNAL_STATE_ORDER",
159
+ "External records are not in nondecreasing partitionKey/orderKey/id order",
160
+ );
161
+ partitionRecords = prior?.partitionKey === record.partitionKey ? partitionRecords + 1 : 1;
162
+ if (partitionRecords > limits.maximumPartitionRecords)
163
+ throw new CustomizationExecutionError(
164
+ "RESOURCE_LIMIT",
165
+ `Partition ${record.partitionKey} exceeds maximumPartitionRecords ${limits.maximumPartitionRecords}`,
166
+ );
167
+ read += 1;
168
+ if (read > written)
169
+ throw new CustomizationExecutionError(
170
+ "EXTERNAL_STATE_FAILURE",
171
+ "External state returned more records than were written",
172
+ );
173
+ prior = record;
174
+ yield record;
175
+ }
176
+ if (read !== written)
177
+ throw new CustomizationExecutionError(
178
+ "EXTERNAL_STATE_TRUNCATED",
179
+ `External state returned ${read} of ${written} records`,
180
+ );
181
+ outcome = "completed";
182
+ } catch (error) {
183
+ primary = error;
184
+ outcome =
185
+ error instanceof CustomizationExecutionError && error.code === "CANCELLED"
186
+ ? "cancelled"
187
+ : "failed";
188
+ throw error;
189
+ } finally {
190
+ if (session !== undefined) {
191
+ try {
192
+ const terminal =
193
+ outcome === "completed"
194
+ ? "completed"
195
+ : primary === undefined
196
+ ? "cancelled"
197
+ : outcome;
198
+ await session.dispose(terminal);
199
+ } catch (error) {
200
+ if (primary === undefined)
201
+ throw new CustomizationExecutionError(
202
+ "EXTERNAL_STATE_FAILURE",
203
+ `External state cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
204
+ );
205
+ }
206
+ }
207
+ }
208
+ }
@@ -0,0 +1,90 @@
1
+ import {
2
+ canonicalJson,
3
+ fnv1a64,
4
+ prepareHistoricalDataset,
5
+ type CustomizationCapabilityFinding,
6
+ type CustomizationResourceLimits,
7
+ type HistoricalDatasetInput,
8
+ type HistoricalObservationRecord,
9
+ type HistoricalRecordDisposition,
10
+ type JsonValue,
11
+ type PreparedHistoricalObservation,
12
+ } from "@actuarial-ts/core";
13
+ import { parseHistoricalDatasetInput, parseHistoricalObservationRecord } from "./customizationContracts.js";
14
+ import {
15
+ externalSortCustomizationRecords,
16
+ type CustomizationExternalRecord,
17
+ type CustomizationExternalStateAdapter,
18
+ } from "./customizationExternalState.js";
19
+
20
+ export type PreparedHistoryStreamEvent =
21
+ | { readonly kind: "observation"; readonly observation: PreparedHistoricalObservation }
22
+ | { readonly kind: "disposition"; readonly disposition: HistoricalRecordDisposition }
23
+ | { readonly kind: "finding"; readonly finding: CustomizationCapabilityFinding };
24
+
25
+ function partitionKey(record: HistoricalObservationRecord): string {
26
+ return canonicalJson([record.sourceNamespace, record.claimId]);
27
+ }
28
+
29
+ function orderKey(record: HistoricalObservationRecord, precedence: HistoricalDatasetInput["revisionPrecedence"]): string {
30
+ const order = precedence === "sequence"
31
+ ? String(record.revision.sequence ?? -1).padStart(20, "0")
32
+ : record.revision.correctedAt ?? "";
33
+ return canonicalJson([record.recordId, record.evaluationDate ?? record.effectiveDate ?? "", order, record.revision.id]);
34
+ }
35
+
36
+ /**
37
+ * Sorts raw revisions in host-owned state and prepares one claim partition at
38
+ * a time. Retained SDK state is bounded by maximumPartitionRecords; oversized
39
+ * claims fail rather than silently spilling or dropping observations.
40
+ */
41
+ export async function* streamPreparedHistoricalDataset(input: {
42
+ readonly runId: string;
43
+ readonly header: Omit<HistoricalDatasetInput, "records">;
44
+ readonly records: AsyncIterable<unknown> | Iterable<unknown>;
45
+ readonly adapter: CustomizationExternalStateAdapter;
46
+ readonly limits: CustomizationResourceLimits;
47
+ readonly signal?: AbortSignal;
48
+ }): AsyncGenerator<PreparedHistoryStreamEvent> {
49
+ const header = parseHistoricalDatasetInput({ ...input.header, records: [] });
50
+ async function* externalRecords(): AsyncGenerator<CustomizationExternalRecord<JsonValue>> {
51
+ for await (const raw of input.records) {
52
+ const record = parseHistoricalObservationRecord(raw);
53
+ const stable = canonicalJson([record.sourceNamespace, record.claimId, record.recordId, record.revision.id]);
54
+ yield {
55
+ id: `record-${fnv1a64(stable)}`,
56
+ partitionKey: partitionKey(record),
57
+ orderKey: orderKey(record, header.revisionPrecedence),
58
+ value: record as unknown as JsonValue,
59
+ };
60
+ }
61
+ }
62
+ let currentPartition: string | undefined;
63
+ let current: HistoricalObservationRecord[] = [];
64
+ const flush = function* (): Generator<PreparedHistoryStreamEvent> {
65
+ if (current.length === 0) return;
66
+ const sourceNamespace = current[0]!.sourceNamespace;
67
+ const claimId = current[0]!.claimId;
68
+ const prepared = prepareHistoricalDataset({
69
+ ...header,
70
+ ...(header.openingBalances === undefined ? {} : { openingBalances: header.openingBalances.filter((balance) => balance.sourceNamespace === sourceNamespace && balance.claimId === claimId) }),
71
+ records: current,
72
+ });
73
+ for (const disposition of prepared.dispositions) yield { kind: "disposition", disposition };
74
+ for (const finding of prepared.findings) yield { kind: "finding", finding };
75
+ for (const observation of prepared.observations) yield { kind: "observation", observation };
76
+ current = [];
77
+ };
78
+ for await (const external of externalSortCustomizationRecords({
79
+ runId: input.runId,
80
+ records: externalRecords(),
81
+ adapter: input.adapter,
82
+ limits: input.limits,
83
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
84
+ })) {
85
+ if (currentPartition !== undefined && external.partitionKey !== currentPartition) yield* flush();
86
+ currentPartition = external.partitionKey;
87
+ current.push(parseHistoricalObservationRecord(external.value));
88
+ }
89
+ yield* flush();
90
+ }
@@ -3,6 +3,10 @@ import {
3
3
  compileDiagnosticDefinition,
4
4
  prepareDiagnosticData,
5
5
  prepareDiagnosticDataCompact,
6
+ reselectCompactDiagnosticMetrics,
7
+ selectCompactDiagnosticSourceGroups,
8
+ selectCompactDiagnosticOrigins,
9
+ selectCompactDiagnosticCoordinates,
6
10
  runMetricDiagnostics,
7
11
  runMetricDiagnosticsCompact,
8
12
  validateDiagnosticGroupingConfiguration,
@@ -307,6 +311,38 @@ const compactPreparedByInput = new WeakMap<
307
311
  CompactValidatedDiagnosticRunInput,
308
312
  CompactPreparedDiagnosticData
309
313
  >();
314
+
315
+ declare const queryContextBrand: unique symbol;
316
+ /** SDK-owned source snapshot. Cloning or serializing this handle loses authority. */
317
+ export interface DiagnosticQueryContext {
318
+ readonly [queryContextBrand]: true;
319
+ }
320
+
321
+ /**
322
+ * Omitted fields inherit the context; null clears filter/expected-cell selection.
323
+ * Group maps/dimensions must match the selected scope; empty records clear them.
324
+ */
325
+ export interface DiagnosticQuery {
326
+ /** Per-query evidence labels; null clears the inherited label. */
327
+ readonly runPresetId?: string | null;
328
+ readonly datasetArtifactId?: string | null;
329
+ readonly filter?: DiagnosticsFilter | null;
330
+ readonly completePeriodCutoffs?: readonly DiagnosticCompletePeriodCutoff[];
331
+ readonly expectedCells?: readonly DiagnosticExpectedCell[] | null;
332
+ readonly groupMap?: Readonly<Record<string, string>>;
333
+ readonly groupDimensions?: Readonly<Record<string, JsonValue>>;
334
+ }
335
+
336
+ const querySchema = z.object({
337
+ runPresetId: tokenSchema.nullable().optional(),
338
+ datasetArtifactId: tokenSchema.nullable().optional(),
339
+ filter: filterSchema.nullable().optional(),
340
+ completePeriodCutoffs: z.array(cutoffSchema).optional(),
341
+ expectedCells: z.array(expectedSchema).nullable().optional(),
342
+ groupMap: recordSchema(tokenSchema).optional(),
343
+ groupDimensions: recordSchema(jsonSchema).optional(),
344
+ }).strict();
345
+ const queryContexts = new WeakMap<DiagnosticQueryContext, CompactValidatedDiagnosticRunInput>();
310
346
  const compactCompleted = new WeakSet<object>();
311
347
  // Retain the exact immutable validated input only while its completed run lives.
312
348
  // Reconstructing from the audit would lose the original optional/raw-value form.
@@ -509,6 +545,71 @@ export function validateDiagnosticRunInputCompact(
509
545
  return result;
510
546
  }
511
547
 
548
+ /**
549
+ * Own and validate source rows and compile the definition once. Queries keep the
550
+ * definition, execution policy, source order and review evidence fixed.
551
+ * Metric selections and eligible clean aggregate source/origin/date/age queries
552
+ * share prepared cells; ineligible inputs still perform full core preparation.
553
+ * Source/origin queries reuse invariant owned review evidence. Date/age queries
554
+ * reuse cell-local checks but recompute monotonic comparisons and controls. Every
555
+ * query enforces review and metric gates, including unchanged evidence validation.
556
+ */
557
+ export function createDiagnosticQueryContext(value: unknown): DiagnosticQueryContext {
558
+ const validated = validateDiagnosticRunInputCompact(value);
559
+ const handle = Object.freeze({}) as DiagnosticQueryContext;
560
+ queryContexts.set(handle, validated);
561
+ return handle;
562
+ }
563
+
564
+ /** Create an authentic per-query input without cloning/reparsing owned source rows. */
565
+ export function validateDiagnosticQueryCompact(
566
+ context: DiagnosticQueryContext,
567
+ query: unknown = {},
568
+ ): CompactValidatedDiagnosticRunInput {
569
+ const base = queryContexts.get(context);
570
+ if (!base) throw new DiagnosticValidationError([{
571
+ domain: "input",
572
+ code: "invalid-input-relationship",
573
+ path: "$",
574
+ message: "Value is not an authentic diagnostic query context",
575
+ }]);
576
+ const undefinedIssues = explicitUndefinedIssues(query);
577
+ if (undefinedIssues.length) throw new DiagnosticValidationError(undefinedIssues);
578
+ const parsed = querySchema.safeParse(query);
579
+ if (!parsed.success) throw issues(parsed.error);
580
+ if (Object.keys(parsed.data).length === 0) return base;
581
+ const ownedQuery = freeze({
582
+ ...parsed.data,
583
+ ...(parsed.data.groupMap === undefined ? {} : { groupMap: sortedRecord<string>(parsed.data.groupMap) }),
584
+ ...(parsed.data.groupDimensions === undefined ? {} : { groupDimensions: sortedRecord<JsonValue>(parsed.data.groupDimensions) }),
585
+ });
586
+ // Only the new query fields require freezing. Every inherited graph belongs
587
+ // to the authentic validated base; never trust caller Object.freeze markers.
588
+ const result = Object.freeze({ ...base, ...ownedQuery }) as CompactValidatedDiagnosticRunInput;
589
+ const projected = Object.keys(ownedQuery).every((key) => ["filter", "groupMap", "groupDimensions", "runPresetId", "datasetArtifactId"].includes(key))
590
+ ? reselectCompactDiagnosticMetrics(compactPreparedByInput.get(base)!, result.filter ?? undefined) ??
591
+ selectCompactDiagnosticSourceGroups(compactPreparedByInput.get(base)!, result.filter ?? undefined) ??
592
+ selectCompactDiagnosticOrigins(compactPreparedByInput.get(base)!, result.filter ?? undefined) ??
593
+ selectCompactDiagnosticCoordinates(compactPreparedByInput.get(base)!, result.filter ?? undefined)
594
+ : undefined;
595
+ const prepared = projected ?? prepareDiagnosticDataCompact(preparationInput(result));
596
+ validateCompactDiagnosticGroupingConfiguration({
597
+ prepared,
598
+ groupMap: result.groupMap,
599
+ groupDimensions: result.groupDimensions,
600
+ });
601
+ compactPreparedByInput.set(result, prepared);
602
+ return result;
603
+ }
604
+
605
+ /** Execute all review and metric gates for this scope, even if another scope passed. */
606
+ export function runDiagnosticQueryCompact(
607
+ context: DiagnosticQueryContext,
608
+ query: DiagnosticQuery = {},
609
+ ): CompactMetricDiagnosticsOutcome {
610
+ return runValidatedMetricDiagnosticsCompact(validateDiagnosticQueryCompact(context, query));
611
+ }
612
+
512
613
  export function assertCompactValidatedDiagnosticRunInput(
513
614
  value: unknown,
514
615
  ): asserts value is CompactValidatedDiagnosticRunInput {
@@ -747,6 +747,30 @@ const compactReceiptFingerprints = new WeakMap<
747
747
  CompactDiagnosticReviewReceipt,
748
748
  string
749
749
  >();
750
+
751
+ // At most one review per live immutable cell graph. Weak ownership releases the
752
+ // retained report with its preparation; changed evidence replaces, never grows,
753
+ // the entry. Metric selection does not affect rule evaluation or structural
754
+ // checks, but every source, definition and evidence dependency below does.
755
+ const reusableCompactReviews = new WeakMap<object, {
756
+ prepared: CompactPreparedDiagnosticData;
757
+ evidenceKey: string;
758
+ scopeKey: string;
759
+ receipt: CompactDiagnosticReviewReceipt;
760
+ }>();
761
+
762
+ function reviewScopeKey(prepared: CompactPreparedDiagnosticData): string {
763
+ const { instanceIds: _instances, ...scope } = prepared.filter ?? {};
764
+ return canonicalJson(scope);
765
+ }
766
+
767
+ function sameReviewSourceGraph(left: CompactPreparedDiagnosticData, right: CompactPreparedDiagnosticData): boolean {
768
+ return left.definition === right.definition && left.cells === right.cells &&
769
+ left.inputAudit === right.inputAudit && left.exposures === right.exposures &&
770
+ left.completePeriodCutoffs === right.completePeriodCutoffs &&
771
+ left.expectedCellsProvided === right.expectedCellsProvided &&
772
+ left.expectedCells === right.expectedCells && left.findings === right.findings;
773
+ }
750
774
  function compactError(message: string, path = "$"): never {
751
775
  throw new DiagnosticValidationError([
752
776
  { domain: "input", code: "invalid-input-relationship", path, message },
@@ -935,6 +959,24 @@ export function reviewPreparedDiagnosticDataCompact(
935
959
  input.evidence === null
936
960
  ? null
937
961
  : validateDiagnosticReviewEvidence(input.evidence);
962
+ // Validated evidence has a fixed JSON schema. Preserve signed zero in its
963
+ // numeric slots; identity JSON intentionally normalizes it, cache ownership
964
+ // must not silently substitute a different caller's raw evidence.
965
+ const evidenceKey = JSON.stringify(evidence, (_key, value: unknown) =>
966
+ typeof value === "number" && Object.is(value, -0) ? { negativeZero: true } : value,
967
+ );
968
+ const scopeKey = reviewScopeKey(input.prepared);
969
+ const previous = reusableCompactReviews.get(input.prepared.cells);
970
+ if (previous && previous.evidenceKey === evidenceKey && previous.scopeKey === scopeKey &&
971
+ sameReviewSourceGraph(previous.prepared, input.prepared)) {
972
+ // The content is genuinely evaluated evidence, not a copied passing flag.
973
+ // Bind the new receipt to this preparation so its exported fingerprint
974
+ // includes this query's actual metric selection and preparation identity.
975
+ const receipt = Object.freeze({ ...previous.receipt, evidence });
976
+ compactReceipts.add(receipt);
977
+ compactReceiptPreparations.set(receipt, input.prepared);
978
+ return receipt;
979
+ }
938
980
  const evaluations = evaluateDiagnosticReviewRulesCompact(input.prepared);
939
981
  const table = new CompactDiagnosticJson();
940
982
  const blocks: FindingBlock[] = [];
@@ -1136,6 +1178,12 @@ export function reviewPreparedDiagnosticDataCompact(
1136
1178
  });
1137
1179
  compactReceipts.add(receipt);
1138
1180
  compactReceiptPreparations.set(receipt, input.prepared);
1181
+ // Very large supplied evidence remains fully reviewed, simply not cached.
1182
+ if (evidenceKey.length <= 65_536 && scopeKey.length <= 65_536) {
1183
+ reusableCompactReviews.set(input.prepared.cells, {
1184
+ prepared: input.prepared, evidenceKey, scopeKey, receipt,
1185
+ });
1186
+ }
1139
1187
  return receipt;
1140
1188
  }
1141
1189