@openparser/lineage 1.0.2

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 (67) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/LICENSE +201 -0
  3. package/README.md +211 -0
  4. package/dist/activity.d.ts +74 -0
  5. package/dist/activity.d.ts.map +1 -0
  6. package/dist/approval.d.ts +24 -0
  7. package/dist/approval.d.ts.map +1 -0
  8. package/dist/builder.d.ts +48 -0
  9. package/dist/builder.d.ts.map +1 -0
  10. package/dist/common.d.ts +34 -0
  11. package/dist/common.d.ts.map +1 -0
  12. package/dist/confidence.d.ts +61 -0
  13. package/dist/confidence.d.ts.map +1 -0
  14. package/dist/derivation.d.ts +163 -0
  15. package/dist/derivation.d.ts.map +1 -0
  16. package/dist/document.d.ts +412 -0
  17. package/dist/document.d.ts.map +1 -0
  18. package/dist/entity.d.ts +126 -0
  19. package/dist/entity.d.ts.map +1 -0
  20. package/dist/fields.d.ts +76 -0
  21. package/dist/fields.d.ts.map +1 -0
  22. package/dist/index.d.ts +21 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +870 -0
  25. package/dist/labels.d.ts +11 -0
  26. package/dist/labels.d.ts.map +1 -0
  27. package/dist/profile/grounding.d.ts +34 -0
  28. package/dist/profile/grounding.d.ts.map +1 -0
  29. package/dist/profile/index.d.ts +14 -0
  30. package/dist/profile/index.d.ts.map +1 -0
  31. package/dist/profile/index.js +1298 -0
  32. package/dist/profile/labels.d.ts +10 -0
  33. package/dist/profile/labels.d.ts.map +1 -0
  34. package/dist/profile/recognition.d.ts +70 -0
  35. package/dist/profile/recognition.d.ts.map +1 -0
  36. package/dist/profile/transform-validation.d.ts +250 -0
  37. package/dist/profile/transform-validation.d.ts.map +1 -0
  38. package/dist/prov.d.ts +40 -0
  39. package/dist/prov.d.ts.map +1 -0
  40. package/dist/relation.d.ts +27 -0
  41. package/dist/relation.d.ts.map +1 -0
  42. package/dist/traverse.d.ts +12 -0
  43. package/dist/traverse.d.ts.map +1 -0
  44. package/dist/validate.d.ts +14 -0
  45. package/dist/validate.d.ts.map +1 -0
  46. package/lineage.schema.json +625 -0
  47. package/package.json +71 -0
  48. package/src/activity.ts +65 -0
  49. package/src/approval.ts +28 -0
  50. package/src/builder.ts +129 -0
  51. package/src/common.ts +58 -0
  52. package/src/confidence.ts +72 -0
  53. package/src/derivation.ts +81 -0
  54. package/src/document.ts +82 -0
  55. package/src/entity.ts +87 -0
  56. package/src/fields.ts +163 -0
  57. package/src/index.ts +123 -0
  58. package/src/labels.ts +13 -0
  59. package/src/profile/grounding.ts +74 -0
  60. package/src/profile/index.ts +67 -0
  61. package/src/profile/labels.ts +28 -0
  62. package/src/profile/recognition.ts +149 -0
  63. package/src/profile/transform-validation.ts +1379 -0
  64. package/src/prov.ts +205 -0
  65. package/src/relation.ts +32 -0
  66. package/src/traverse.ts +91 -0
  67. package/src/validate.ts +221 -0
@@ -0,0 +1,1379 @@
1
+ import Decimal from 'decimal.js';
2
+ import { DateTime } from 'luxon';
3
+ import { createHash } from 'node:crypto';
4
+ import { z } from 'zod';
5
+ import { JsonValueSchema, type JsonValue } from '../common';
6
+
7
+ export const TRANSFORM_VERIFICATION_DOCS =
8
+ 'https://docs.openparser.dev/lineage/openparser/transform-verification';
9
+ export const TRANSFORM_PROOF_ATTRIBUTE = 'openparser:transformProof';
10
+ export const MAX_TRANSFORM_PROOF_BYTES = 2_048;
11
+ /** Admission budget so a newly accepted policy can always serialize inside a 2 KiB proof. */
12
+ export const MAX_TRANSFORM_INTENT_PARAMETER_BYTES = 1_024;
13
+
14
+ export const TRANSFORM_DOCS_ANCHORS = [
15
+ 'date-time-format',
16
+ 'numeric-format',
17
+ 'quantity-magnitude',
18
+ 'unit-conversion',
19
+ 'currency-code',
20
+ 'boolean-alias',
21
+ 'text-normalization',
22
+ 'enum-alias',
23
+ ] as const;
24
+ export type TransformDocsAnchor = (typeof TRANSFORM_DOCS_ANCHORS)[number];
25
+
26
+ export const TransformProofClassSchema = z.enum([
27
+ 'safe_equivalence',
28
+ 'exact_conversion',
29
+ 'policy_application',
30
+ ]);
31
+ export type TransformProofClass = z.infer<typeof TransformProofClassSchema>;
32
+
33
+ export type DateTimeKind = 'date' | 'date-time' | 'time';
34
+
35
+ type FormatComponents = {
36
+ yearQuality: 'none' | 'short' | 'full';
37
+ month: boolean;
38
+ day: boolean;
39
+ hour: boolean;
40
+ minute: boolean;
41
+ second: boolean;
42
+ millisecond: boolean;
43
+ offset: boolean;
44
+ zone: boolean;
45
+ };
46
+
47
+ const FORMAT_TOKENS = (
48
+ [
49
+ ['yyyyy', 'year', 'full'],
50
+ ['yyyy', 'year', 'full'],
51
+ ['yyy', 'year', 'short'],
52
+ ['yy', 'year', 'short'],
53
+ ['y', 'year', 'short'],
54
+ ['uuuuu', 'year', 'full'],
55
+ ['uuuu', 'year', 'full'],
56
+ ['uuu', 'year', 'short'],
57
+ ['uu', 'year', 'short'],
58
+ ['u', 'year', 'short'],
59
+ ['MMMMM', 'month'],
60
+ ['MMMM', 'month'],
61
+ ['MMM', 'month'],
62
+ ['MM', 'month'],
63
+ ['M', 'month'],
64
+ ['LLLLL', 'month'],
65
+ ['LLLL', 'month'],
66
+ ['LLL', 'month'],
67
+ ['LL', 'month'],
68
+ ['L', 'month'],
69
+ ['dd', 'day'],
70
+ ['d', 'day'],
71
+ ['HH', 'hour'],
72
+ ['H', 'hour'],
73
+ ['hh', 'hour'],
74
+ ['h', 'hour'],
75
+ ['kk', 'hour'],
76
+ ['k', 'hour'],
77
+ ['mm', 'minute'],
78
+ ['m', 'minute'],
79
+ ['ss', 'second'],
80
+ ['s', 'second'],
81
+ ['SSS', 'millisecond'],
82
+ ['SS', 'millisecond'],
83
+ ['S', 'millisecond'],
84
+ ['ZZZZZ', 'offset'],
85
+ ['ZZZZ', 'offset'],
86
+ ['ZZZ', 'offset'],
87
+ ['ZZ', 'offset'],
88
+ ['Z', 'offset'],
89
+ ['zzz', 'zone'],
90
+ ['zz', 'zone'],
91
+ ['z', 'zone'],
92
+ ] as const
93
+ ).toSorted((left, right) => right[0].length - left[0].length);
94
+
95
+ function unquotedFormatChunks(format: string): string[] {
96
+ const chunks: string[] = [];
97
+ let i = 0;
98
+ let current = '';
99
+ while (i < format.length) {
100
+ if (format[i] === "'") {
101
+ if (current) chunks.push(current);
102
+ current = '';
103
+ i += 1;
104
+ while (i < format.length) {
105
+ if (format[i] === "'" && format[i + 1] === "'") {
106
+ i += 2;
107
+ continue;
108
+ }
109
+ if (format[i] === "'") {
110
+ i += 1;
111
+ break;
112
+ }
113
+ i += 1;
114
+ }
115
+ continue;
116
+ }
117
+ current += format[i];
118
+ i += 1;
119
+ }
120
+ if (current) chunks.push(current);
121
+ return chunks;
122
+ }
123
+
124
+ function inspectFormat(format: string): FormatComponents {
125
+ const components: FormatComponents = {
126
+ yearQuality: 'none',
127
+ month: false,
128
+ day: false,
129
+ hour: false,
130
+ minute: false,
131
+ second: false,
132
+ millisecond: false,
133
+ offset: false,
134
+ zone: false,
135
+ };
136
+ for (const chunk of unquotedFormatChunks(format)) {
137
+ let i = 0;
138
+ while (i < chunk.length) {
139
+ const match = FORMAT_TOKENS.find(([token]) => chunk.startsWith(token, i));
140
+ if (!match) {
141
+ i += 1;
142
+ continue;
143
+ }
144
+ const [, field, yearQuality] = match;
145
+ if (field === 'year') {
146
+ if (yearQuality === 'short' || components.yearQuality === 'short') {
147
+ components.yearQuality = 'short';
148
+ } else {
149
+ components.yearQuality = 'full';
150
+ }
151
+ } else {
152
+ components[field] = true;
153
+ }
154
+ i += match[0].length;
155
+ }
156
+ }
157
+ return components;
158
+ }
159
+
160
+ function formatHasRequiredKindTokens(kind: DateTimeKind, components: FormatComponents): boolean {
161
+ if (components.yearQuality === 'short') return false;
162
+ if (kind === 'date') {
163
+ return (
164
+ components.yearQuality === 'full' &&
165
+ components.month &&
166
+ components.day &&
167
+ !components.hour &&
168
+ !components.minute &&
169
+ !components.second &&
170
+ !components.millisecond &&
171
+ !components.offset &&
172
+ !components.zone
173
+ );
174
+ }
175
+ if (kind === 'time') {
176
+ return (
177
+ components.hour &&
178
+ components.minute &&
179
+ components.second &&
180
+ components.yearQuality === 'none' &&
181
+ !components.month &&
182
+ !components.day
183
+ );
184
+ }
185
+ return (
186
+ components.yearQuality === 'full' &&
187
+ components.month &&
188
+ components.day &&
189
+ components.hour &&
190
+ components.minute &&
191
+ components.second
192
+ );
193
+ }
194
+
195
+ function optionalTimeComponentsAlign(source: FormatComponents, target: FormatComponents): boolean {
196
+ return (
197
+ source.millisecond === target.millisecond &&
198
+ source.offset === target.offset &&
199
+ source.zone === target.zone
200
+ );
201
+ }
202
+
203
+ export function dateTimeFormatIssue(
204
+ kind: DateTimeKind,
205
+ sourceFormats: string[],
206
+ targetFormat: string
207
+ ): string | undefined {
208
+ const target = inspectFormat(targetFormat);
209
+ if (!formatHasRequiredKindTokens(kind, target)) {
210
+ return 'target format must preserve every required component and must not use a two-digit year';
211
+ }
212
+ for (const format of sourceFormats) {
213
+ const source = inspectFormat(format);
214
+ if (!formatHasRequiredKindTokens(kind, source)) {
215
+ return 'source format must preserve every required component and must not use a two-digit year';
216
+ }
217
+ if (!optionalTimeComponentsAlign(source, target)) {
218
+ return 'source and target formats must not add or drop milliseconds, offset, or zone';
219
+ }
220
+ }
221
+ return undefined;
222
+ }
223
+
224
+ /**
225
+ * Built-in Luxon source formats for format-only JSON Schema `date` / `date-time` /
226
+ * `time` intent. Versioned independently of `openparser.transform.date-time-format.v1`
227
+ * so accepted sources can grow without rewriting already-emitted proofs.
228
+ *
229
+ * v1 selection is conservative and not demo-specific:
230
+ * - ISO 8601 calendar date and the matching offset-bearing ISO date-time/time forms
231
+ * - Unambiguous year-first numeric calendar dates
232
+ * - Explicit English civil date families (day-first and US month-first, full or
233
+ * abbreviated month names, and "N day of Month Year")
234
+ *
235
+ * Locale-ambiguous numeric day/month orders (`MM/dd/yyyy`, `dd/MM/yyyy`, …),
236
+ * two-digit years, weekday-prefixed forms, and natural-language parsers are
237
+ * omitted. A claim `source_format` may only prioritize a catalog member; it
238
+ * cannot add omitted forms or shrink the candidate set.
239
+ */
240
+ export const DATE_SOURCE_CATALOG_ID = 'openparser.transform.date-source-catalog' as const;
241
+ export const DATE_SOURCE_CATALOG_VERSION = 1 as const;
242
+ export const FORMAT_ONLY_DATE_TARGETS = {
243
+ date: 'yyyy-MM-dd',
244
+ 'date-time': "yyyy-MM-dd'T'HH:mm:ssZZ",
245
+ time: 'HH:mm:ssZZ',
246
+ } as const;
247
+ export const DATE_SOURCE_CATALOG_V1: Record<DateTimeKind, readonly string[]> = {
248
+ date: [
249
+ 'yyyy-MM-dd',
250
+ 'yyyy/MM/dd',
251
+ 'yyyy.MM.dd',
252
+ 'd MMMM yyyy',
253
+ 'dd MMMM yyyy',
254
+ 'MMMM d, yyyy',
255
+ 'MMMM dd, yyyy',
256
+ 'd MMM yyyy',
257
+ 'dd MMM yyyy',
258
+ 'MMM d, yyyy',
259
+ 'MMM dd, yyyy',
260
+ "d 'day of' MMMM yyyy",
261
+ "dd 'day of' MMMM yyyy",
262
+ ],
263
+ 'date-time': ["yyyy-MM-dd'T'HH:mm:ssZZ", "yyyy-MM-dd'T'HH:mm:ssZ"],
264
+ time: ['HH:mm:ssZZ', 'HH:mm:ssZ'],
265
+ };
266
+
267
+ export function trustedDateSourceFormats(
268
+ kind: DateTimeKind,
269
+ targetFormat: string = FORMAT_ONLY_DATE_TARGETS[kind]
270
+ ): string[] {
271
+ return DATE_SOURCE_CATALOG_V1[kind].filter(
272
+ (format) => dateTimeFormatIssue(kind, [format], targetFormat) === undefined
273
+ );
274
+ }
275
+
276
+ export function formatOnlyDateTimeParameters(kind: DateTimeKind) {
277
+ return {
278
+ kind,
279
+ sourceFormats: trustedDateSourceFormats(kind, FORMAT_ONLY_DATE_TARGETS[kind]),
280
+ targetFormat: FORMAT_ONLY_DATE_TARGETS[kind],
281
+ locale: 'en-US',
282
+ zonePolicy: kind === 'date' ? ('civil' as const) : ('preserve' as const),
283
+ sourceCatalog: {
284
+ id: DATE_SOURCE_CATALOG_ID,
285
+ version: DATE_SOURCE_CATALOG_VERSION,
286
+ },
287
+ };
288
+ }
289
+
290
+ const DateSourceCatalogRefSchema = z
291
+ .object({
292
+ id: z.literal(DATE_SOURCE_CATALOG_ID),
293
+ version: z.literal(DATE_SOURCE_CATALOG_VERSION),
294
+ })
295
+ .strict();
296
+
297
+ const DateTimeParametersSchema = z
298
+ .object({
299
+ kind: z.enum(['date', 'date-time', 'time']),
300
+ sourceFormats: z.array(z.string().min(1).max(128)).max(16).default([]),
301
+ targetFormat: z.string().min(1).max(128),
302
+ locale: z.string().min(2).max(64).default('en-US'),
303
+ zonePolicy: z.enum(['civil', 'preserve', 'utc', 'named']).default('civil'),
304
+ zone: z.string().min(1).max(128).optional(),
305
+ sourceCatalog: DateSourceCatalogRefSchema.optional(),
306
+ })
307
+ .strict()
308
+ .superRefine((value, ctx) => {
309
+ if (value.zonePolicy === 'named' && !value.zone) {
310
+ ctx.addIssue({ code: 'custom', path: ['zone'], message: 'named zone policy requires zone' });
311
+ }
312
+ if (value.kind === 'date' && value.zonePolicy !== 'civil') {
313
+ ctx.addIssue({
314
+ code: 'custom',
315
+ path: ['zonePolicy'],
316
+ message: 'date-only transforms must remain civil',
317
+ });
318
+ }
319
+ if (/[Zz]/.test(value.targetFormat) && value.zonePolicy === 'civil') {
320
+ ctx.addIssue({
321
+ code: 'custom',
322
+ path: ['zonePolicy'],
323
+ message: 'target formats with an offset require preserve, utc, or named zone policy',
324
+ });
325
+ }
326
+ });
327
+
328
+ const NumericParametersSchema = z
329
+ .object({
330
+ decimalSeparator: z.enum(['.', ',']).default('.'),
331
+ groupingSeparator: z.enum([',', '.', ' ', '\u00a0']).optional(),
332
+ prefixes: z.array(z.string().max(16)).max(16).default([]),
333
+ suffixes: z.array(z.string().max(16)).max(16).default([]),
334
+ accountingParentheses: z.boolean().default(false),
335
+ })
336
+ .strict()
337
+ .superRefine((value, ctx) => {
338
+ if (value.groupingSeparator === value.decimalSeparator) {
339
+ ctx.addIssue({
340
+ code: 'custom',
341
+ path: ['groupingSeparator'],
342
+ message: 'grouping and decimal separators must differ',
343
+ });
344
+ }
345
+ });
346
+
347
+ function policyMap<T>(keyMax: number, maxKeys: number, schema: z.ZodType<T>) {
348
+ return z
349
+ .record(z.string().min(1).max(keyMax), schema)
350
+ .refine((value) => Object.keys(value).length <= maxKeys, `at most ${maxKeys} keys`);
351
+ }
352
+
353
+ const QuantityParametersSchema = z
354
+ .object({
355
+ allowedUnits: policyMap(32, 16, z.string().min(1).max(32)),
356
+ targetUnit: z.string().min(1).max(32),
357
+ })
358
+ .strict();
359
+
360
+ const UnitConversionParametersSchema = z
361
+ .object({
362
+ sourceUnits: z.array(z.string().min(1).max(32)).min(1).max(32),
363
+ targetUnit: z.string().min(1).max(32),
364
+ })
365
+ .strict();
366
+
367
+ const CurrencyParametersSchema = z
368
+ .object({
369
+ acceptedCodes: z
370
+ .array(z.string().regex(/^[A-Z]{3}$/))
371
+ .min(1)
372
+ .max(64),
373
+ aliases: policyMap(64, 16, z.string().regex(/^[A-Z]{3}$/)).default({}),
374
+ symbolMap: policyMap(8, 16, z.string().regex(/^[A-Z]{3}$/)).default({}),
375
+ })
376
+ .strict();
377
+
378
+ const BooleanParametersSchema = z
379
+ .object({
380
+ aliases: policyMap(64, 16, z.boolean()),
381
+ caseSensitive: z.boolean().default(false),
382
+ })
383
+ .strict();
384
+
385
+ const TextNormalizationParametersSchema = z
386
+ .object({
387
+ operations: z
388
+ .array(z.enum(['trim', 'collapse_whitespace', 'lowercase', 'uppercase', 'nfkc']))
389
+ .min(1)
390
+ .max(8),
391
+ locale: z.string().min(2).max(64).default('en-US'),
392
+ })
393
+ .strict();
394
+
395
+ const EnumAliasParametersSchema = z
396
+ .object({
397
+ aliases: policyMap(256, 16, z.union([z.string().max(256), z.number()])),
398
+ caseSensitive: z.boolean().default(true),
399
+ })
400
+ .strict();
401
+
402
+ export const TransformIntentSchema = z.discriminatedUnion('operation', [
403
+ z
404
+ .object({ operation: z.literal('date_time_format'), parameters: DateTimeParametersSchema })
405
+ .strict(),
406
+ z
407
+ .object({ operation: z.literal('numeric_format'), parameters: NumericParametersSchema })
408
+ .strict(),
409
+ z
410
+ .object({ operation: z.literal('quantity_magnitude'), parameters: QuantityParametersSchema })
411
+ .strict(),
412
+ z
413
+ .object({ operation: z.literal('unit_conversion'), parameters: UnitConversionParametersSchema })
414
+ .strict(),
415
+ z
416
+ .object({ operation: z.literal('currency_code'), parameters: CurrencyParametersSchema })
417
+ .strict(),
418
+ z.object({ operation: z.literal('boolean_alias'), parameters: BooleanParametersSchema }).strict(),
419
+ z
420
+ .object({
421
+ operation: z.literal('text_normalization'),
422
+ parameters: TextNormalizationParametersSchema,
423
+ })
424
+ .strict(),
425
+ z.object({ operation: z.literal('enum_alias'), parameters: EnumAliasParametersSchema }).strict(),
426
+ ]);
427
+ export type TransformIntent = z.infer<typeof TransformIntentSchema>;
428
+ export type TransformOperation = TransformIntent['operation'];
429
+
430
+ export const TRANSFORM_OPERATIONS = [
431
+ 'date_time_format',
432
+ 'numeric_format',
433
+ 'quantity_magnitude',
434
+ 'unit_conversion',
435
+ 'currency_code',
436
+ 'boolean_alias',
437
+ 'text_normalization',
438
+ 'enum_alias',
439
+ ] as const satisfies readonly TransformOperation[];
440
+
441
+ export const TransformClaimSchema = z
442
+ .object({
443
+ operation: z.enum(['', ...TRANSFORM_OPERATIONS]),
444
+ parameters: z
445
+ .record(z.string().max(64), z.union([z.string().max(128), z.number(), z.boolean()]))
446
+ .refine((value) => Object.keys(value).length <= 16, 'at most 16 claim parameters')
447
+ .default({}),
448
+ reason: z.string().max(2_000),
449
+ confidence: z.enum(['', 'low', 'medium', 'high']),
450
+ })
451
+ .strict();
452
+ export type TransformClaim = z.infer<typeof TransformClaimSchema>;
453
+
454
+ export const TRANSFORM_PROOF_OPERAND_KEYS = [
455
+ 'bounded',
456
+ 'canonical',
457
+ 'magnitude',
458
+ 'mappedValue',
459
+ 'matchedSourceFormat',
460
+ 'output',
461
+ 'outputCode',
462
+ 'outputDecimal',
463
+ 'outputHash',
464
+ 'policyHash',
465
+ 'resolvedCode',
466
+ 'sourceDecimal',
467
+ 'sourceHash',
468
+ 'sourceMagnitude',
469
+ 'sourceUnit',
470
+ 'targetMagnitude',
471
+ 'targetUnit',
472
+ ] as const;
473
+
474
+ function sanitizeProofOperands(
475
+ value: unknown
476
+ ): Record<string, z.infer<typeof TransformProofOperandValueSchema>> | undefined {
477
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
478
+ const next = Object.fromEntries(
479
+ Object.entries(value).filter(([key]) =>
480
+ (TRANSFORM_PROOF_OPERAND_KEYS as readonly string[]).includes(key)
481
+ )
482
+ );
483
+ return Object.keys(next).length ? next : undefined;
484
+ }
485
+
486
+ export const TransformProofHashSchema = z
487
+ .object({
488
+ algorithm: z.literal('sha256'),
489
+ digest: z.string().regex(/^[a-f0-9]{64}$/),
490
+ })
491
+ .strict();
492
+
493
+ export const TransformProofOperandValueSchema = z.union([
494
+ z.string().max(128),
495
+ z.number().finite(),
496
+ z.boolean(),
497
+ TransformProofHashSchema,
498
+ ]);
499
+
500
+ function recordSize<T>(maxKeys: number, schema: z.ZodType<T>) {
501
+ return z
502
+ .record(z.string().min(1).max(64), schema)
503
+ .refine((value) => Object.keys(value).length <= maxKeys, `at most ${maxKeys} keys`);
504
+ }
505
+
506
+ export function isTrustedTransformDocsHref(href: string): boolean {
507
+ if (href === TRANSFORM_VERIFICATION_DOCS) return true;
508
+ const prefix = `${TRANSFORM_VERIFICATION_DOCS}#`;
509
+ if (!href.startsWith(prefix)) return false;
510
+ const anchor = href.slice(prefix.length);
511
+ return (TRANSFORM_DOCS_ANCHORS as readonly string[]).includes(anchor);
512
+ }
513
+
514
+ export function trustedTransformDocsHref(methodId?: string): string {
515
+ if (!methodId) return TRANSFORM_VERIFICATION_DOCS;
516
+ const descriptor = TRANSFORM_VALIDATORS.find((entry) => entry.methodId === methodId);
517
+ return descriptor
518
+ ? `${TRANSFORM_VERIFICATION_DOCS}#${descriptor.docsAnchor}`
519
+ : TRANSFORM_VERIFICATION_DOCS;
520
+ }
521
+
522
+ const proofByteLength = (input: unknown) =>
523
+ new TextEncoder().encode(JSON.stringify(input)).byteLength;
524
+
525
+ export function trustedIntentParametersFitProof(parameters: unknown): boolean {
526
+ return proofByteLength(parameters) <= MAX_TRANSFORM_INTENT_PARAMETER_BYTES;
527
+ }
528
+
529
+ export const TransformProofSchema = z
530
+ .object({
531
+ methodId: z.string().min(1).max(128),
532
+ methodVersion: z.number().int().positive(),
533
+ result: z.enum(['verified', 'contradicted', 'not_applicable']),
534
+ proofClass: TransformProofClassSchema,
535
+ scope: z.string().min(1).max(256),
536
+ trustedParameters: recordSize(32, JsonValueSchema),
537
+ operands: recordSize(16, TransformProofOperandValueSchema)
538
+ .refine(
539
+ (value) =>
540
+ Object.keys(value).every((key) =>
541
+ (TRANSFORM_PROOF_OPERAND_KEYS as readonly string[]).includes(key)
542
+ ),
543
+ 'unsupported proof operand key'
544
+ )
545
+ .optional(),
546
+ checks: recordSize(32, z.boolean()),
547
+ implementation: z
548
+ .object({
549
+ library: z.string().min(1).max(128),
550
+ version: z.string().min(1).max(64),
551
+ runtime: z.string().min(1).max(128).optional(),
552
+ locale: z.string().min(1).max(64).optional(),
553
+ zone: z.string().min(1).max(128).optional(),
554
+ })
555
+ .strict(),
556
+ limitations: z.string().min(1).max(512),
557
+ docsHref: z
558
+ .string()
559
+ .max(256)
560
+ .refine(isTrustedTransformDocsHref, 'untrusted transform docs href'),
561
+ })
562
+ .strict()
563
+ .superRefine((value, ctx) => {
564
+ if (proofByteLength(value) > MAX_TRANSFORM_PROOF_BYTES) {
565
+ ctx.addIssue({
566
+ code: 'custom',
567
+ message: `transform proof exceeds ${MAX_TRANSFORM_PROOF_BYTES} bytes`,
568
+ });
569
+ }
570
+ });
571
+ export type TransformProof = z.infer<typeof TransformProofSchema>;
572
+
573
+ export function parseTransformProof(value: unknown): TransformProof | undefined {
574
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
575
+ const record = value as Record<string, unknown>;
576
+ const methodId = typeof record.methodId === 'string' ? record.methodId : undefined;
577
+ const candidate = {
578
+ methodId: record.methodId,
579
+ methodVersion: record.methodVersion,
580
+ result: record.result,
581
+ proofClass: record.proofClass,
582
+ scope: record.scope,
583
+ trustedParameters: record.trustedParameters,
584
+ ...(sanitizeProofOperands(record.operands)
585
+ ? { operands: sanitizeProofOperands(record.operands) }
586
+ : {}),
587
+ checks: record.checks,
588
+ implementation: record.implementation,
589
+ limitations: record.limitations,
590
+ docsHref: trustedTransformDocsHref(methodId),
591
+ };
592
+ const parsed = TransformProofSchema.safeParse(candidate);
593
+ return parsed.success ? parsed.data : undefined;
594
+ }
595
+
596
+ export type TransformVerificationResult = {
597
+ result: TransformProof['result'];
598
+ proof: TransformProof;
599
+ };
600
+
601
+ export type TransformValidatorDescriptor = {
602
+ operation: TransformOperation;
603
+ methodId: string;
604
+ version: number;
605
+ label: string;
606
+ summary: string;
607
+ docsAnchor: TransformDocsAnchor;
608
+ acceptedSourceTypes: readonly string[];
609
+ acceptedOutputTypes: readonly string[];
610
+ proofClass: TransformProofClass;
611
+ parameterSchema: z.ZodType;
612
+ parameterJsonSchema: Record<string, unknown>;
613
+ library: { name: string; version: string };
614
+ limitations: string;
615
+ verify(input: {
616
+ quote: string;
617
+ output: unknown;
618
+ parameters: unknown;
619
+ preferredSourceFormat?: string;
620
+ }): TransformVerificationResult;
621
+ };
622
+
623
+ const DECIMAL_VERSION = '10.6.0';
624
+ const LUXON_VERSION = '3.7.2';
625
+ const ISO_4217_CODES = new Set(
626
+ 'AED AFN ALL AMD ANG AOA ARS AUD AWG AZN BAM BBD BDT BGN BHD BIF BMD BND BOB BOV BRL BSD BTN BWP BYN BZD CAD CDF CHE CHF CHW CLF CLP CNY COP COU CRC CUC CUP CVE CZK DJF DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD HKD HNL HRK HTG HUF IDR ILS INR IQD IRR ISK JMD JOD JPY KES KGS KHR KMF KPW KRW KWD KYD KZT LAK LBP LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT MOP MRU MUR MVR MWK MXN MXV MYR MZN NAD NGN NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR PLN PYG QAR RON RSD RUB RWF SAR SBD SCR SDG SEK SGD SHP SLE SLL SOS SRD SSP STN SVC SYP SZL THB TJS TMT TND TOP TRY TTD TWD TZS UAH UGX USD USN UYI UYU UYW UZS VED VES VND VUV WST XAF XAG XAU XBA XBB XBC XBD XCD XDR XOF XPD XPF XPT XSU XTS XUA XXX YER ZAR ZMW ZWG ZWL'.split(
627
+ ' '
628
+ )
629
+ );
630
+
631
+ function descriptorBase(
632
+ input: Omit<TransformValidatorDescriptor, 'verify' | 'parameterJsonSchema'> & {
633
+ parameterJsonSchema?: Record<string, unknown>;
634
+ }
635
+ ): Omit<TransformValidatorDescriptor, 'verify'> {
636
+ return {
637
+ ...input,
638
+ parameterJsonSchema: input.parameterJsonSchema ?? z.toJSONSchema(input.parameterSchema),
639
+ };
640
+ }
641
+
642
+ function sha256Operand(value: string): { algorithm: 'sha256'; digest: string } {
643
+ return {
644
+ algorithm: 'sha256',
645
+ digest: createHash('sha256').update(value, 'utf8').digest('hex'),
646
+ };
647
+ }
648
+
649
+ function boundedOperand(value: string): string | { algorithm: 'sha256'; digest: string } {
650
+ return value.length <= 128 ? value : sha256Operand(value);
651
+ }
652
+
653
+ function proof(
654
+ descriptor: Omit<TransformValidatorDescriptor, 'verify'>,
655
+ result: TransformProof['result'],
656
+ parameters: Record<string, unknown>,
657
+ extras: Pick<TransformProof, 'checks'> &
658
+ Partial<Pick<TransformProof, 'operands'>> & {
659
+ runtime?: string;
660
+ locale?: string;
661
+ zone?: string;
662
+ proofClass?: TransformProofClass;
663
+ }
664
+ ): TransformVerificationResult {
665
+ const jsonParameters = JSON.parse(JSON.stringify(parameters)) as Record<string, JsonValue>;
666
+ const jsonOperands = extras.operands
667
+ ? (JSON.parse(JSON.stringify(extras.operands)) as TransformProof['operands'])
668
+ : undefined;
669
+ const value = {
670
+ methodId: descriptor.methodId,
671
+ methodVersion: descriptor.version,
672
+ result,
673
+ proofClass: extras.proofClass ?? descriptor.proofClass,
674
+ scope: descriptor.summary,
675
+ trustedParameters: jsonParameters,
676
+ ...(jsonOperands ? { operands: jsonOperands } : {}),
677
+ checks: extras.checks,
678
+ implementation: {
679
+ library: descriptor.library.name,
680
+ version: descriptor.library.version,
681
+ ...(extras.runtime ? { runtime: extras.runtime.slice(0, 128) } : {}),
682
+ ...(extras.locale ? { locale: extras.locale } : {}),
683
+ ...(extras.zone ? { zone: extras.zone } : {}),
684
+ },
685
+ limitations: descriptor.limitations,
686
+ docsHref: `${TRANSFORM_VERIFICATION_DOCS}#${descriptor.docsAnchor}`,
687
+ };
688
+ const next = boundedProof(value);
689
+ return { result: next.result, proof: next };
690
+ }
691
+
692
+ function isIncompleteTrustedPolicy(parameters: unknown): boolean {
693
+ return (
694
+ typeof parameters === 'object' &&
695
+ parameters !== null &&
696
+ !Array.isArray(parameters) &&
697
+ Object.keys(parameters).length === 1 &&
698
+ (parameters as { bounded?: unknown }).bounded === true
699
+ );
700
+ }
701
+
702
+ function downgradeProofTooLarge(value: Record<string, unknown>): TransformProof {
703
+ const digest = sha256Operand(JSON.stringify(value.trustedParameters ?? {}));
704
+ const parsed = TransformProofSchema.safeParse({
705
+ methodId: value.methodId,
706
+ methodVersion: value.methodVersion,
707
+ result: 'not_applicable',
708
+ proofClass: value.proofClass,
709
+ scope: value.scope,
710
+ trustedParameters: {
711
+ reason: 'proof_too_large',
712
+ policyHash: digest,
713
+ },
714
+ operands: { policyHash: digest },
715
+ checks: { proofFitsBound: false },
716
+ implementation: value.implementation,
717
+ limitations: value.limitations,
718
+ docsHref: value.docsHref,
719
+ });
720
+ if (!parsed.success) {
721
+ throw new Error('transform proof descriptor exceeds bounded metadata limit');
722
+ }
723
+ return parsed.data;
724
+ }
725
+
726
+ function boundedProof(value: unknown): TransformProof {
727
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
728
+ throw new Error('transform proof descriptor exceeds bounded metadata limit');
729
+ }
730
+ const record = value as Record<string, unknown>;
731
+ const parsed = TransformProofSchema.safeParse(value);
732
+ if (parsed.success) {
733
+ if (
734
+ parsed.data.result === 'verified' &&
735
+ isIncompleteTrustedPolicy(parsed.data.trustedParameters)
736
+ ) {
737
+ return downgradeProofTooLarge(record);
738
+ }
739
+ return parsed.data;
740
+ }
741
+ return downgradeProofTooLarge(record);
742
+ }
743
+
744
+ function escaped(value: string): string {
745
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
746
+ }
747
+
748
+ function outputDecimal(value: unknown): Decimal | undefined {
749
+ if (typeof value === 'number' && Number.isFinite(value)) return new Decimal(value.toString());
750
+ if (typeof value === 'string' && /^[+-]?\d+(?:\.\d+)?$/.test(value)) return new Decimal(value);
751
+ return undefined;
752
+ }
753
+
754
+ function parseTrustedDecimal(
755
+ quote: string,
756
+ parameters: z.infer<typeof NumericParametersSchema>
757
+ ): Decimal | undefined {
758
+ let text = quote.normalize('NFKC').trim();
759
+ let negative = false;
760
+ if (parameters.accountingParentheses && text.startsWith('(') && text.endsWith(')')) {
761
+ negative = true;
762
+ text = text.slice(1, -1).trim();
763
+ }
764
+ const stripEdge = (values: string[], edge: 'prefix' | 'suffix') => {
765
+ const match = [...values]
766
+ .sort((a, b) => b.length - a.length)
767
+ .find((value) => (edge === 'prefix' ? text.startsWith(value) : text.endsWith(value)));
768
+ if (!match) return;
769
+ text =
770
+ edge === 'prefix'
771
+ ? text.slice(match.length).trim()
772
+ : text.slice(0, text.length - match.length).trim();
773
+ };
774
+ stripEdge(parameters.prefixes, 'prefix');
775
+ stripEdge(parameters.suffixes, 'suffix');
776
+ const decimal = escaped(parameters.decimalSeparator);
777
+ const grouping = parameters.groupingSeparator ? escaped(parameters.groupingSeparator) : undefined;
778
+ const integer = grouping ? `(?:\\d{1,3}(?:${grouping}\\d{3})+|\\d+)` : '\\d+';
779
+ if (!new RegExp(`^[+-]?${integer}(?:${decimal}\\d+)?$`).test(text)) return undefined;
780
+ const canonical = text
781
+ .replaceAll(parameters.groupingSeparator ?? '\u0000', '')
782
+ .replace(parameters.decimalSeparator, '.');
783
+ try {
784
+ const parsed = new Decimal(canonical);
785
+ return negative ? parsed.negated() : parsed;
786
+ } catch {
787
+ return undefined;
788
+ }
789
+ }
790
+
791
+ function dateTimeRuntime(): string | undefined {
792
+ if (typeof process === 'undefined') return undefined;
793
+ return `node ${process.versions.node}; ICU ${process.versions.icu ?? 'unknown'}`;
794
+ }
795
+
796
+ function canonicalDateTime(
797
+ kind: DateTimeKind,
798
+ value: DateTime,
799
+ components: FormatComponents
800
+ ): string {
801
+ if (kind === 'date') return value.toFormat('yyyy-MM-dd');
802
+ const fraction = components.millisecond ? '.SSS' : '';
803
+ if (kind === 'time') {
804
+ const offset = components.offset ? 'ZZ' : '';
805
+ const zone = components.zone ? ' zzz' : '';
806
+ return value.toFormat(`HH:mm:ss${fraction}${offset}${zone}`).trim();
807
+ }
808
+ if (components.offset || components.zone) return value.toUTC().toISO() ?? '';
809
+ return value.toFormat(`yyyy-MM-dd'T'HH:mm:ss${fraction}`);
810
+ }
811
+
812
+ const dateTimeBase = descriptorBase({
813
+ operation: 'date_time_format',
814
+ methodId: 'openparser.transform.date-time-format.v1',
815
+ version: 1,
816
+ label: 'Date/time formatting',
817
+ summary: 'Exact reformatting of one civil date/time value or instant',
818
+ docsAnchor: 'date-time-format',
819
+ acceptedSourceTypes: ['string'],
820
+ acceptedOutputTypes: ['string'],
821
+ proofClass: 'safe_equivalence',
822
+ parameterSchema: DateTimeParametersSchema,
823
+ library: { name: 'luxon', version: LUXON_VERSION },
824
+ limitations:
825
+ 'Only explicit Luxon formats with four-digit years; source and target must preserve the same required components. Two-digit years, defaulted fields, and silent millisecond/offset/zone changes are not proven. Format-only JSON Schema date intent uses the versioned built-in source catalog; a claim source_format cannot add or narrow that catalog.',
826
+ });
827
+ function resolveTrustedDateTimeParameters(
828
+ parameters: z.infer<typeof DateTimeParametersSchema>
829
+ ): z.infer<typeof DateTimeParametersSchema> {
830
+ if (parameters.sourceFormats.length === 0 || parameters.sourceCatalog) {
831
+ return {
832
+ ...parameters,
833
+ sourceFormats: trustedDateSourceFormats(parameters.kind, parameters.targetFormat),
834
+ sourceCatalog: {
835
+ id: DATE_SOURCE_CATALOG_ID,
836
+ version: DATE_SOURCE_CATALOG_VERSION,
837
+ },
838
+ };
839
+ }
840
+ return parameters;
841
+ }
842
+
843
+ function prioritizeSourceFormats(formats: string[], preferred?: string): string[] {
844
+ if (!preferred || !formats.includes(preferred)) return formats;
845
+ return [preferred, ...formats.filter((format) => format !== preferred)];
846
+ }
847
+
848
+ const dateTimeValidator: TransformValidatorDescriptor = {
849
+ ...dateTimeBase,
850
+ verify({ quote, output, parameters, preferredSourceFormat }) {
851
+ const parsed = DateTimeParametersSchema.safeParse(parameters);
852
+ if (!parsed.success || typeof output !== 'string') {
853
+ return proof(dateTimeBase, 'not_applicable', {}, { checks: { parametersValid: false } });
854
+ }
855
+ const p = resolveTrustedDateTimeParameters(parsed.data);
856
+ if (p.sourceFormats.length === 0) {
857
+ return proof(dateTimeBase, 'not_applicable', p, {
858
+ checks: {
859
+ sourceTokensValid: false,
860
+ targetTokensValid: formatHasRequiredKindTokens(p.kind, inspectFormat(p.targetFormat)),
861
+ fullSourceConsumed: false,
862
+ sourceRoundTrip: false,
863
+ },
864
+ locale: p.locale,
865
+ });
866
+ }
867
+ const zone = p.zonePolicy === 'utc' ? 'UTC' : p.zonePolicy === 'named' ? p.zone : undefined;
868
+ const targetComponents = inspectFormat(p.targetFormat);
869
+ const preferred =
870
+ preferredSourceFormat && p.sourceFormats.includes(preferredSourceFormat)
871
+ ? preferredSourceFormat
872
+ : undefined;
873
+ const candidates = prioritizeSourceFormats(p.sourceFormats, preferred).flatMap((format) => {
874
+ const sourceComponents = inspectFormat(format);
875
+ if (
876
+ !formatHasRequiredKindTokens(p.kind, sourceComponents) ||
877
+ !formatHasRequiredKindTokens(p.kind, targetComponents) ||
878
+ !optionalTimeComponentsAlign(sourceComponents, targetComponents)
879
+ ) {
880
+ return [];
881
+ }
882
+ const value = DateTime.fromFormat(quote, format, {
883
+ locale: p.locale,
884
+ zone,
885
+ setZone: p.zonePolicy !== 'civil',
886
+ });
887
+ if (!value.isValid || value.toFormat(format) !== quote) return [];
888
+ if (p.zonePolicy === 'named' && value.getPossibleOffsets().length !== 1) return [];
889
+ return [{ value, format, components: sourceComponents }];
890
+ });
891
+ if (!candidates.length) {
892
+ return proof(dateTimeBase, 'not_applicable', p, {
893
+ checks: {
894
+ sourceTokensValid: true,
895
+ targetTokensValid: formatHasRequiredKindTokens(p.kind, targetComponents),
896
+ fullSourceConsumed: false,
897
+ sourceRoundTrip: false,
898
+ unambiguous: false,
899
+ },
900
+ locale: p.locale,
901
+ zone,
902
+ });
903
+ }
904
+ const canonical = candidates.map(({ value, components }) =>
905
+ canonicalDateTime(p.kind, value, components)
906
+ );
907
+ const converged = new Set(canonical).size === 1;
908
+ const rendered = candidates[0]!.value.toFormat(p.targetFormat);
909
+ const target = DateTime.fromFormat(rendered, p.targetFormat, {
910
+ locale: p.locale,
911
+ zone,
912
+ setZone: p.zonePolicy !== 'civil',
913
+ });
914
+ const targetRoundTrip = target.isValid && target.toFormat(p.targetFormat) === rendered;
915
+ const exactOutput = output === rendered;
916
+ const result = converged && targetRoundTrip && exactOutput ? 'verified' : 'contradicted';
917
+ const matchedSourceFormat =
918
+ preferred && candidates.some((candidate) => candidate.format === preferred)
919
+ ? preferred
920
+ : candidates[0]!.format;
921
+ return proof(dateTimeBase, result, p, {
922
+ operands: {
923
+ sourceHash: sha256Operand(quote),
924
+ matchedSourceFormat: boundedOperand(matchedSourceFormat),
925
+ canonical: boundedOperand(canonical[0]!),
926
+ output: boundedOperand(output),
927
+ },
928
+ checks: {
929
+ sourceTokensValid: true,
930
+ targetTokensValid: true,
931
+ componentsPreserved: true,
932
+ fullSourceConsumed: true,
933
+ sourceRoundTrip: true,
934
+ formatsConverged: converged,
935
+ targetRoundTrip,
936
+ exactOutput,
937
+ },
938
+ runtime: dateTimeRuntime(),
939
+ locale: p.locale,
940
+ zone: zone ?? p.zonePolicy,
941
+ });
942
+ },
943
+ };
944
+
945
+ const numericBase = descriptorBase({
946
+ operation: 'numeric_format',
947
+ methodId: 'openparser.transform.numeric-format.v1',
948
+ version: 1,
949
+ label: 'Numeric formatting',
950
+ summary: 'Exact decimal equivalence after a declared representation change',
951
+ docsAnchor: 'numeric-format',
952
+ acceptedSourceTypes: ['string'],
953
+ acceptedOutputTypes: ['number', 'integer', 'string'],
954
+ proofClass: 'safe_equivalence',
955
+ parameterSchema: NumericParametersSchema,
956
+ library: { name: 'decimal.js', version: DECIMAL_VERSION },
957
+ limitations: 'Only declared separators and edge affixes are accepted.',
958
+ });
959
+ const numericValidator: TransformValidatorDescriptor = {
960
+ ...numericBase,
961
+ verify({ quote, output, parameters }) {
962
+ const parsed = NumericParametersSchema.safeParse(parameters);
963
+ const right = outputDecimal(output);
964
+ if (!parsed.success || !right) {
965
+ return proof(numericBase, 'not_applicable', {}, { checks: { parametersValid: false } });
966
+ }
967
+ const left = parseTrustedDecimal(quote, parsed.data);
968
+ if (!left) {
969
+ return proof(numericBase, 'not_applicable', parsed.data, {
970
+ checks: { wholeQuoteMatched: false },
971
+ });
972
+ }
973
+ const exact = left.eq(right);
974
+ return proof(numericBase, exact ? 'verified' : 'contradicted', parsed.data, {
975
+ operands: { sourceDecimal: left.toString(), outputDecimal: right.toString() },
976
+ checks: { wholeQuoteMatched: true, exactDecimalEquality: exact },
977
+ });
978
+ },
979
+ };
980
+
981
+ const quantityBase = descriptorBase({
982
+ operation: 'quantity_magnitude',
983
+ methodId: 'openparser.transform.quantity-magnitude.v1',
984
+ version: 1,
985
+ label: 'Quantity magnitude',
986
+ summary: 'Removal of an explicitly accepted unit while preserving magnitude',
987
+ docsAnchor: 'quantity-magnitude',
988
+ acceptedSourceTypes: ['string'],
989
+ acceptedOutputTypes: ['number', 'integer', 'string'],
990
+ proofClass: 'safe_equivalence',
991
+ parameterSchema: QuantityParametersSchema,
992
+ library: { name: 'decimal.js', version: DECIMAL_VERSION },
993
+ limitations: 'The complete quote must be one quantity and the unit must be allowlisted.',
994
+ });
995
+ const quantityValidator: TransformValidatorDescriptor = {
996
+ ...quantityBase,
997
+ verify({ quote, output, parameters }) {
998
+ const parsed = QuantityParametersSchema.safeParse(parameters);
999
+ const right = outputDecimal(output);
1000
+ if (!parsed.success || !right) {
1001
+ return proof(quantityBase, 'not_applicable', {}, { checks: { parametersValid: false } });
1002
+ }
1003
+ const aliases = Object.keys(parsed.data.allowedUnits).sort((a, b) => b.length - a.length);
1004
+ const match = quote
1005
+ .normalize('NFKC')
1006
+ .trim()
1007
+ .match(new RegExp(`^([+-]?\\d+(?:\\.\\d+)?)\\s*(${aliases.map(escaped).join('|')})$`, 'i'));
1008
+ if (!match) {
1009
+ return proof(quantityBase, 'not_applicable', parsed.data, {
1010
+ checks: { wholeQuoteMatched: false },
1011
+ });
1012
+ }
1013
+ const canonicalUnit = Object.entries(parsed.data.allowedUnits).find(
1014
+ ([alias]) => alias.toLocaleLowerCase('en-US') === match[2]!.toLocaleLowerCase('en-US')
1015
+ )?.[1];
1016
+ const exact = canonicalUnit === parsed.data.targetUnit && new Decimal(match[1]!).eq(right);
1017
+ return proof(quantityBase, exact ? 'verified' : 'contradicted', parsed.data, {
1018
+ operands: {
1019
+ magnitude: match[1]!,
1020
+ sourceUnit: canonicalUnit!,
1021
+ targetUnit: parsed.data.targetUnit,
1022
+ },
1023
+ checks: {
1024
+ wholeQuoteMatched: true,
1025
+ acceptedUnit: Boolean(canonicalUnit),
1026
+ exactMagnitude: exact,
1027
+ },
1028
+ });
1029
+ },
1030
+ };
1031
+
1032
+ const UNIT_RATIOS: Record<string, { dimension: string; baseRatio: string }> = {
1033
+ mm: { dimension: 'length', baseRatio: '0.001' },
1034
+ cm: { dimension: 'length', baseRatio: '0.01' },
1035
+ m: { dimension: 'length', baseRatio: '1' },
1036
+ km: { dimension: 'length', baseRatio: '1000' },
1037
+ in: { dimension: 'length', baseRatio: '0.0254' },
1038
+ ft: { dimension: 'length', baseRatio: '0.3048' },
1039
+ s: { dimension: 'duration', baseRatio: '1' },
1040
+ min: { dimension: 'duration', baseRatio: '60' },
1041
+ h: { dimension: 'duration', baseRatio: '3600' },
1042
+ day: { dimension: 'duration', baseRatio: '86400' },
1043
+ month: { dimension: 'calendar_duration', baseRatio: '1' },
1044
+ year: { dimension: 'calendar_duration', baseRatio: '12' },
1045
+ g: { dimension: 'mass', baseRatio: '1' },
1046
+ kg: { dimension: 'mass', baseRatio: '1000' },
1047
+ lb: { dimension: 'mass', baseRatio: '453.59237' },
1048
+ };
1049
+
1050
+ const unitBase = descriptorBase({
1051
+ operation: 'unit_conversion',
1052
+ methodId: 'openparser.transform.unit-conversion.v1',
1053
+ version: 1,
1054
+ label: 'Unit conversion',
1055
+ summary: 'Exact conversion between allowlisted units in one dimension',
1056
+ docsAnchor: 'unit-conversion',
1057
+ acceptedSourceTypes: ['string'],
1058
+ acceptedOutputTypes: ['number', 'integer', 'string'],
1059
+ proofClass: 'exact_conversion',
1060
+ parameterSchema: UnitConversionParametersSchema,
1061
+ library: { name: 'decimal.js + OpenParser unit table', version: `${DECIMAL_VERSION}/1` },
1062
+ limitations: 'Only the versioned unit table is supported; no contextual or ambiguous units.',
1063
+ });
1064
+ const unitValidator: TransformValidatorDescriptor = {
1065
+ ...unitBase,
1066
+ verify({ quote, output, parameters }) {
1067
+ const parsed = UnitConversionParametersSchema.safeParse(parameters);
1068
+ const right = outputDecimal(output);
1069
+ if (!parsed.success || !right) {
1070
+ return proof(unitBase, 'not_applicable', {}, { checks: { parametersValid: false } });
1071
+ }
1072
+ const match = quote
1073
+ .normalize('NFKC')
1074
+ .trim()
1075
+ .match(/^([+-]?\d+(?:\.\d+)?)\s*([A-Za-z]+)$/);
1076
+ const sourceUnit = match?.[2];
1077
+ const source = sourceUnit ? UNIT_RATIOS[sourceUnit] : undefined;
1078
+ const target = UNIT_RATIOS[parsed.data.targetUnit];
1079
+ if (
1080
+ !match ||
1081
+ !sourceUnit ||
1082
+ !parsed.data.sourceUnits.includes(sourceUnit) ||
1083
+ !source ||
1084
+ !target
1085
+ ) {
1086
+ return proof(unitBase, 'not_applicable', parsed.data, {
1087
+ checks: { wholeQuoteMatched: Boolean(match), supportedUnits: false },
1088
+ });
1089
+ }
1090
+ if (source.dimension !== target.dimension) {
1091
+ return proof(unitBase, 'contradicted', parsed.data, {
1092
+ operands: { sourceUnit, targetUnit: parsed.data.targetUnit },
1093
+ checks: { sameDimension: false },
1094
+ });
1095
+ }
1096
+ const converted = new Decimal(match[1]!).mul(source.baseRatio).div(target.baseRatio);
1097
+ const exact = converted.eq(right);
1098
+ return proof(unitBase, exact ? 'verified' : 'contradicted', parsed.data, {
1099
+ operands: {
1100
+ sourceMagnitude: match[1]!,
1101
+ sourceUnit,
1102
+ targetMagnitude: converted.toString(),
1103
+ targetUnit: parsed.data.targetUnit,
1104
+ },
1105
+ checks: { wholeQuoteMatched: true, sameDimension: true, exactDecimalEquality: exact },
1106
+ });
1107
+ },
1108
+ };
1109
+
1110
+ type CurrencySignal = { code: string; index: number; kind: 'iso' | 'alias' | 'symbol' };
1111
+
1112
+ function findBoundedTokens(text: string, token: string): number[] {
1113
+ const indices: number[] = [];
1114
+ const pattern = new RegExp(`(?<![A-Za-z0-9])${escaped(token)}(?![A-Za-z0-9])`, 'gi');
1115
+ for (const match of text.matchAll(pattern)) {
1116
+ if (match.index !== undefined) indices.push(match.index);
1117
+ }
1118
+ return indices;
1119
+ }
1120
+
1121
+ function collectCurrencySignals(
1122
+ text: string,
1123
+ parameters: z.infer<typeof CurrencyParametersSchema>
1124
+ ): CurrencySignal[] {
1125
+ const signals: CurrencySignal[] = [];
1126
+ const isoCodes = new Set([...ISO_4217_CODES, ...parameters.acceptedCodes]);
1127
+ for (const code of isoCodes) {
1128
+ for (const index of findBoundedTokens(text, code)) {
1129
+ signals.push({ code: code.toUpperCase(), index, kind: 'iso' });
1130
+ }
1131
+ }
1132
+ for (const [alias, code] of Object.entries(parameters.aliases)) {
1133
+ for (const index of findBoundedTokens(text, alias)) {
1134
+ signals.push({ code, index, kind: 'alias' });
1135
+ }
1136
+ }
1137
+ const covered = new Array<boolean>(text.length).fill(false);
1138
+ const symbols = Object.entries(parameters.symbolMap).toSorted(
1139
+ (left, right) => right[0].length - left[0].length
1140
+ );
1141
+ for (const [symbol, code] of symbols) {
1142
+ let from = 0;
1143
+ while (from <= text.length - symbol.length) {
1144
+ const index = text.indexOf(symbol, from);
1145
+ if (index < 0) break;
1146
+ from = index + 1;
1147
+ const before = index === 0 ? '' : text[index - 1]!;
1148
+ if (/\p{L}/u.test(before)) continue;
1149
+ if (covered.slice(index, index + symbol.length).some(Boolean)) continue;
1150
+ for (let i = index; i < index + symbol.length; i += 1) covered[i] = true;
1151
+ signals.push({ code, index, kind: 'symbol' });
1152
+ }
1153
+ }
1154
+ return signals;
1155
+ }
1156
+
1157
+ const currencyBase = descriptorBase({
1158
+ operation: 'currency_code',
1159
+ methodId: 'openparser.transform.currency-code.v1',
1160
+ version: 1,
1161
+ label: 'Currency code derivation',
1162
+ summary: 'Derivation of an ISO code from an explicit token or declared symbol policy',
1163
+ docsAnchor: 'currency-code',
1164
+ acceptedSourceTypes: ['string'],
1165
+ acceptedOutputTypes: ['string'],
1166
+ proofClass: 'safe_equivalence',
1167
+ parameterSchema: CurrencyParametersSchema,
1168
+ library: { name: 'OpenParser ISO currency token table', version: '1' },
1169
+ limitations:
1170
+ 'A bare $ proves nothing unless trusted intent maps that standalone token. $ inside C$, A$, NZ$, HK$, or S$ is not a USD signal. Conflicting ISO codes or currency signals are not applied.',
1171
+ });
1172
+ const currencyValidator: TransformValidatorDescriptor = {
1173
+ ...currencyBase,
1174
+ verify({ quote, output, parameters }) {
1175
+ const parsed = CurrencyParametersSchema.safeParse(parameters);
1176
+ if (!parsed.success || typeof output !== 'string') {
1177
+ return proof(currencyBase, 'not_applicable', {}, { checks: { parametersValid: false } });
1178
+ }
1179
+ const text = quote.normalize('NFKC').trim();
1180
+ const accepted = new Set(parsed.data.acceptedCodes);
1181
+ const signals = collectCurrencySignals(text, parsed.data);
1182
+ const codes = [...new Set(signals.map((signal) => signal.code))];
1183
+ if (codes.length > 1) {
1184
+ return proof(currencyBase, 'not_applicable', parsed.data, {
1185
+ checks: { trustedTokenFound: true, unambiguous: false },
1186
+ });
1187
+ }
1188
+ const code = codes[0];
1189
+ if (!code || !accepted.has(code)) {
1190
+ return proof(currencyBase, 'not_applicable', parsed.data, {
1191
+ checks: { trustedTokenFound: false, unambiguous: true },
1192
+ });
1193
+ }
1194
+ const policy = signals.every((signal) => signal.kind === 'symbol');
1195
+ const exact = output === code;
1196
+ return proof(currencyBase, exact ? 'verified' : 'contradicted', parsed.data, {
1197
+ proofClass: policy ? 'policy_application' : 'safe_equivalence',
1198
+ operands: { resolvedCode: code, outputCode: output },
1199
+ checks: { trustedTokenFound: true, unambiguous: true, exactOutput: exact },
1200
+ });
1201
+ },
1202
+ };
1203
+
1204
+ const booleanBase = descriptorBase({
1205
+ operation: 'boolean_alias',
1206
+ methodId: 'openparser.transform.boolean-alias.v1',
1207
+ version: 1,
1208
+ label: 'Boolean alias',
1209
+ summary: 'Application of a trusted exact alias map to a whole quote',
1210
+ docsAnchor: 'boolean-alias',
1211
+ acceptedSourceTypes: ['string'],
1212
+ acceptedOutputTypes: ['boolean'],
1213
+ proofClass: 'policy_application',
1214
+ parameterSchema: BooleanParametersSchema,
1215
+ library: { name: 'OpenParser alias matcher', version: '1' },
1216
+ limitations: 'No sentiment, negation, or natural-language interpretation.',
1217
+ });
1218
+ const booleanValidator: TransformValidatorDescriptor = {
1219
+ ...booleanBase,
1220
+ verify({ quote, output, parameters }) {
1221
+ const parsed = BooleanParametersSchema.safeParse(parameters);
1222
+ if (!parsed.success || typeof output !== 'boolean') {
1223
+ return proof(booleanBase, 'not_applicable', {}, { checks: { parametersValid: false } });
1224
+ }
1225
+ const normalize = (value: string) =>
1226
+ parsed.data.caseSensitive ? value : value.toLocaleLowerCase('en-US');
1227
+ const match = Object.entries(parsed.data.aliases).find(
1228
+ ([alias]) => normalize(alias) === normalize(quote.normalize('NFKC').trim())
1229
+ );
1230
+ if (!match) {
1231
+ return proof(booleanBase, 'not_applicable', parsed.data, {
1232
+ checks: { wholeQuoteMatched: false },
1233
+ });
1234
+ }
1235
+ const exact = match[1] === output;
1236
+ return proof(booleanBase, exact ? 'verified' : 'contradicted', parsed.data, {
1237
+ operands: { mappedValue: match[1], output },
1238
+ checks: { wholeQuoteMatched: true, exactOutput: exact },
1239
+ });
1240
+ },
1241
+ };
1242
+
1243
+ function applyTextOperations(
1244
+ value: string,
1245
+ parameters: z.infer<typeof TextNormalizationParametersSchema>
1246
+ ): string {
1247
+ return parameters.operations.reduce((text, operation) => {
1248
+ switch (operation) {
1249
+ case 'trim':
1250
+ return text.trim();
1251
+ case 'collapse_whitespace':
1252
+ return text.replace(/\s+/g, ' ');
1253
+ case 'lowercase':
1254
+ return text.toLocaleLowerCase(parameters.locale);
1255
+ case 'uppercase':
1256
+ return text.toLocaleUpperCase(parameters.locale);
1257
+ case 'nfkc':
1258
+ return text.normalize('NFKC');
1259
+ }
1260
+ }, value);
1261
+ }
1262
+
1263
+ const textBase = descriptorBase({
1264
+ operation: 'text_normalization',
1265
+ methodId: 'openparser.transform.text-normalization.v1',
1266
+ version: 1,
1267
+ label: 'Controlled text normalization',
1268
+ summary: 'Application of an ordered, enumerated text normalization plan',
1269
+ docsAnchor: 'text-normalization',
1270
+ acceptedSourceTypes: ['string'],
1271
+ acceptedOutputTypes: ['string'],
1272
+ proofClass: 'policy_application',
1273
+ parameterSchema: TextNormalizationParametersSchema,
1274
+ library: { name: 'ECMAScript String', version: 'ES2024' },
1275
+ limitations: 'Only the recorded operations run; semantic paraphrasing is never verified.',
1276
+ });
1277
+ const textValidator: TransformValidatorDescriptor = {
1278
+ ...textBase,
1279
+ verify({ quote, output, parameters }) {
1280
+ const parsed = TextNormalizationParametersSchema.safeParse(parameters);
1281
+ if (!parsed.success || typeof output !== 'string') {
1282
+ return proof(textBase, 'not_applicable', {}, { checks: { parametersValid: false } });
1283
+ }
1284
+ const normalized = applyTextOperations(quote, parsed.data);
1285
+ const exact = output === normalized;
1286
+ return proof(textBase, exact ? 'verified' : 'contradicted', parsed.data, {
1287
+ operands: { sourceHash: sha256Operand(quote), outputHash: sha256Operand(output) },
1288
+ checks: { operationsApplied: true, exactOutput: exact },
1289
+ locale: parsed.data.locale,
1290
+ });
1291
+ },
1292
+ };
1293
+
1294
+ const enumBase = descriptorBase({
1295
+ operation: 'enum_alias',
1296
+ methodId: 'openparser.transform.enum-alias.v1',
1297
+ version: 1,
1298
+ label: 'Enum alias',
1299
+ summary: 'Application of a trusted exact alias table',
1300
+ docsAnchor: 'enum-alias',
1301
+ acceptedSourceTypes: ['string'],
1302
+ acceptedOutputTypes: ['string', 'number', 'integer'],
1303
+ proofClass: 'policy_application',
1304
+ parameterSchema: EnumAliasParametersSchema,
1305
+ library: { name: 'OpenParser alias matcher', version: '1' },
1306
+ limitations: 'Only exact whole-quote aliases are accepted.',
1307
+ });
1308
+ const enumValidator: TransformValidatorDescriptor = {
1309
+ ...enumBase,
1310
+ verify({ quote, output, parameters }) {
1311
+ const parsed = EnumAliasParametersSchema.safeParse(parameters);
1312
+ if (!parsed.success || (typeof output !== 'string' && typeof output !== 'number')) {
1313
+ return proof(enumBase, 'not_applicable', {}, { checks: { parametersValid: false } });
1314
+ }
1315
+ const normalize = (value: string) =>
1316
+ parsed.data.caseSensitive ? value : value.toLocaleLowerCase('en-US');
1317
+ const match = Object.entries(parsed.data.aliases).find(
1318
+ ([alias]) => normalize(alias) === normalize(quote.normalize('NFKC').trim())
1319
+ );
1320
+ if (!match) {
1321
+ return proof(enumBase, 'not_applicable', parsed.data, {
1322
+ checks: { wholeQuoteMatched: false },
1323
+ });
1324
+ }
1325
+ const exact = match[1] === output;
1326
+ return proof(enumBase, exact ? 'verified' : 'contradicted', parsed.data, {
1327
+ operands: {
1328
+ mappedValue: typeof match[1] === 'string' ? boundedOperand(match[1]) : match[1],
1329
+ output: typeof output === 'string' ? boundedOperand(output) : output,
1330
+ },
1331
+ checks: { wholeQuoteMatched: true, exactOutput: exact },
1332
+ });
1333
+ },
1334
+ };
1335
+
1336
+ export const TRANSFORM_VALIDATORS = [
1337
+ dateTimeValidator,
1338
+ numericValidator,
1339
+ quantityValidator,
1340
+ unitValidator,
1341
+ currencyValidator,
1342
+ booleanValidator,
1343
+ textValidator,
1344
+ enumValidator,
1345
+ ] as const satisfies readonly TransformValidatorDescriptor[];
1346
+
1347
+ const VALIDATOR_BY_OPERATION = new Map(
1348
+ TRANSFORM_VALIDATORS.map((descriptor) => [descriptor.operation, descriptor])
1349
+ );
1350
+
1351
+ export function transformValidatorDescriptor(
1352
+ operation: TransformOperation
1353
+ ): TransformValidatorDescriptor {
1354
+ return VALIDATOR_BY_OPERATION.get(operation)!;
1355
+ }
1356
+
1357
+ function claimedDateSourceFormat(claim: TransformClaim | undefined): string | undefined {
1358
+ if (!claim || claim.operation !== 'date_time_format') return undefined;
1359
+ const value = claim.parameters.source_format;
1360
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
1361
+ }
1362
+
1363
+ /** Verify only the validator selected by trusted request intent. Model claims never authorize it. */
1364
+ export function verifyTransform(input: {
1365
+ quote: string;
1366
+ output: unknown;
1367
+ intent: TransformIntent;
1368
+ claim?: TransformClaim;
1369
+ }): TransformVerificationResult {
1370
+ const intent = TransformIntentSchema.parse(input.intent);
1371
+ const descriptor = transformValidatorDescriptor(intent.operation);
1372
+ const hinted = claimedDateSourceFormat(input.claim);
1373
+ return descriptor.verify({
1374
+ quote: input.quote,
1375
+ output: input.output,
1376
+ parameters: intent.parameters,
1377
+ ...(intent.operation === 'date_time_format' && hinted ? { preferredSourceFormat: hinted } : {}),
1378
+ });
1379
+ }