From 2cc49f0bad1ba1e0855083f2e63d2bc002449fa4 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 02:48:46 -0700 Subject: [PATCH 1/4] feat(contracts): define pinned exact integer revision 3 --- .../value-contract-fixtures.v3.json | 36 ++++++ docs/maintainers/value-contracts.v3.json | 50 +++++++++ src/contracts/value-contract.ts | 85 +++++++++----- test/value_contracts_v3.test.ts | 106 ++++++++++++++++++ 4 files changed, 247 insertions(+), 30 deletions(-) create mode 100644 docs/maintainers/value-contract-fixtures.v3.json create mode 100644 docs/maintainers/value-contracts.v3.json create mode 100644 test/value_contracts_v3.test.ts diff --git a/docs/maintainers/value-contract-fixtures.v3.json b/docs/maintainers/value-contract-fixtures.v3.json new file mode 100644 index 00000000..f5d419af --- /dev/null +++ b/docs/maintainers/value-contract-fixtures.v3.json @@ -0,0 +1,36 @@ +{ + "revision": 3, + "integerCases": [ + { + "logicalDecimal": "0", + "envelope": { "__tywrap__": "integer", "codecVersion": 2, "encoding": "decimal", "value": "0" } + }, + { + "logicalDecimal": "9007199254740991", + "envelope": { "__tywrap__": "integer", "codecVersion": 2, "encoding": "decimal", "value": "9007199254740991" } + }, + { + "logicalDecimal": "18446744073709551617", + "envelope": { "__tywrap__": "integer", "codecVersion": 2, "encoding": "decimal", "value": "18446744073709551617" } + }, + { + "logicalDecimal": "-18446744073709551617", + "envelope": { + "__tywrap__": "integer", + "codecVersion": 2, + "encoding": "decimal", + "value": "-18446744073709551617" + } + } + ], + "rejectedDecimals": ["-0", "+1", "01", "1.0", "1e3", "١", ""], + "floatNegativeZero": { + "envelope": { "__tywrap__": "float", "codecVersion": 2, "encoding": "negative-zero" }, + "decodedAs": "negative zero" + }, + "migration": { + "default": "Python int remains a safe JavaScript number under revision 2", + "opted": "Python int becomes bigint at every declared integer node under revision 3", + "caller": "bigint arithmetic and JSON persistence require explicit handling" + } +} diff --git a/docs/maintainers/value-contracts.v3.json b/docs/maintainers/value-contracts.v3.json new file mode 100644 index 00000000..014a2f39 --- /dev/null +++ b/docs/maintainers/value-contracts.v3.json @@ -0,0 +1,50 @@ +{ + "revision": 3, + "status": "bounded-internal-prototype", + "base": { + "file": "value-contracts.v2.json", + "revision": 2, + "sha256": "554f84b75126513250a7eb773294347fe4dc42262e4ac52f86e7aa19cc65bb4e" + }, + "transport": "tywrap/1 value RPC", + "valueEnvelopeCodecVersion": 2, + "callPolicy": "bigint-v2", + "requiredCapability": "exactIntegerDecimalV2", + "rules": { + "integer-exact": { + "logicalKind": "Python int, excluding bool, on an opted call", + "contract": { + "kind": "integer-exact", + "wire": "decimal-string-envelope", + "decodedAs": "bigint", + "constraint": "exact-integer" + }, + "wire": { + "__tywrap__": "integer", + "codecVersion": 2, + "encoding": "decimal", + "value": "canonical signed decimal string" + }, + "decimalGrammar": "^(?:0|[1-9][0-9]*|-[1-9][0-9]*)$", + "maximumDigitsWithoutSign": 4096, + "composition": "apply the exact integer rule at every selected sequence, tuple, union, and record node" + }, + "float-negative-zero": { + "logicalKind": "Python float equal to negative zero on an opted call", + "wire": { + "__tywrap__": "float", + "codecVersion": 2, + "encoding": "negative-zero" + }, + "decodedAs": "JavaScript negative zero", + "otherFiniteFloats": "JSON number under the inherited finite float rule" + } + }, + "compatibility": { + "revision2Default": "unchanged safe-number integer rule", + "unknownRevision": "reject", + "missingCapability": "reject before sending a tagged request", + "oldClientNewProvider": "revision 2 behavior unless the call opts in", + "arrowInt64": "keep the existing Arrow rule" + } +} diff --git a/src/contracts/value-contract.ts b/src/contracts/value-contract.ts index d8bc033b..63eb1db4 100644 --- a/src/contracts/value-contract.ts +++ b/src/contracts/value-contract.ts @@ -1,10 +1,17 @@ /** Internal value policy consumed by codecs and callable-contract generation. */ export const VALUE_CONTRACT_REVISION = 2 as const; +/** Exact integer mode uses a separate semantic contract revision. */ +export const VALUE_CONTRACT_V3_REVISION = 3 as const; + +export type ValueContractRevision = + | typeof VALUE_CONTRACT_REVISION + | typeof VALUE_CONTRACT_V3_REVISION; + /** JSON numbers can carry Python integers exactly only within this range. */ export const MAX_SAFE_JSON_INTEGER = 2 ** 53 - 1; -export type ValueContract = +type CommonValueContractLeaf = | { readonly kind: 'null'; readonly wire: 'json'; readonly decodedAs: 'null' } | { readonly kind: 'boolean'; readonly wire: 'json'; readonly decodedAs: 'boolean' } | { @@ -21,36 +28,11 @@ export type ValueContract = } | { readonly kind: 'string'; readonly wire: 'json'; readonly decodedAs: 'string' } | { readonly kind: 'bytes'; readonly wire: 'base64-envelope'; readonly decodedAs: 'Uint8Array' } - | { - readonly kind: 'sequence'; - readonly wire: 'json'; - readonly decodedAs: 'array'; - readonly item: ValueContract; - } - | { - readonly kind: 'tuple'; - readonly wire: 'json'; - readonly decodedAs: 'array'; - readonly items: readonly ValueContract[]; - } - | { - readonly kind: 'union'; - readonly wire: 'selected-option'; - readonly decodedAs: 'selected-option'; - readonly options: readonly [ValueContract, ValueContract, ...ValueContract[]]; - } - | { - readonly kind: 'record'; - readonly wire: 'json'; - readonly decodedAs: 'object'; - readonly fields: readonly ValueContractField[]; - readonly additionalValues?: ValueContract; - } | { readonly kind: 'ndarray-float16'; readonly wire: 'arrow' | 'json-fallback'; readonly decodedAs: 'nested-array-or-scalar'; - readonly element: Extract; + readonly element: Extract; readonly dtype: 'float16'; readonly rank?: number; } @@ -59,7 +41,7 @@ export type ValueContract = readonly wire: 'ndarray-envelope'; readonly decodedAs: 'tensor-record'; readonly dtype: 'torch.float16'; - readonly value: Extract; + readonly value: Extract; } | { readonly kind: 'unsupported'; @@ -69,9 +51,52 @@ export type ValueContract = readonly guidance: string; }; -export interface ValueContractField { +type CompositeValueContract = + | { + readonly kind: 'sequence'; + readonly wire: 'json'; + readonly decodedAs: 'array'; + readonly item: TValue; + } + | { + readonly kind: 'tuple'; + readonly wire: 'json'; + readonly decodedAs: 'array'; + readonly items: readonly TValue[]; + } + | { + readonly kind: 'union'; + readonly wire: 'selected-option'; + readonly decodedAs: 'selected-option'; + readonly options: readonly [TValue, TValue, ...TValue[]]; + } + | { + readonly kind: 'record'; + readonly wire: 'json'; + readonly decodedAs: 'object'; + readonly fields: readonly ValueContractField[]; + readonly additionalValues?: TValue; + }; + +/** Revision 2 remains the default safe-number value contract. */ +export type ValueContract = CommonValueContractLeaf | CompositeValueContract; + +export interface ExactIntegerValueContract { + readonly kind: 'integer-exact'; + readonly wire: 'decimal-string-envelope'; + readonly decodedAs: 'bigint'; + readonly constraint: 'exact-integer'; +} + +/** Revision 3 adds exact integers without changing revision 2. */ +export type ValueContractV3 = + | CommonValueContractLeaf + | ExactIntegerValueContract + | CompositeValueContract; + +export interface ValueContractField { readonly name: string; - readonly value: ValueContract; + readonly value: TValue; readonly required: boolean; } diff --git a/test/value_contracts_v3.test.ts b/test/value_contracts_v3.test.ts new file mode 100644 index 00000000..70db9b6e --- /dev/null +++ b/test/value_contracts_v3.test.ts @@ -0,0 +1,106 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + VALUE_CONTRACT_REVISION, + VALUE_CONTRACT_V3_REVISION, + type ExactIntegerValueContract, + type ValueContractV3, +} from '../src/contracts/value-contract.js'; + +const specification = JSON.parse( + readFileSync(new URL('../docs/maintainers/value-contracts.v3.json', import.meta.url), 'utf8') +) as { + revision: number; + base: { file: string; revision: number; sha256: string }; + valueEnvelopeCodecVersion: number; + callPolicy: string; + requiredCapability: string; + rules: { + 'integer-exact': { + contract: ExactIntegerValueContract; + decimalGrammar: string; + maximumDigitsWithoutSign: number; + }; + }; +}; + +const fixtures = JSON.parse( + readFileSync( + new URL('../docs/maintainers/value-contract-fixtures.v3.json', import.meta.url), + 'utf8' + ) +) as { + revision: number; + integerCases: readonly { + logicalDecimal: string; + envelope: { __tywrap__: string; codecVersion: number; encoding: string; value: string }; + }[]; + rejectedDecimals: readonly string[]; +}; + +describe('exact integer value policy', () => { + it('pins revision 3 to the unchanged revision-2 source', () => { + const base = readFileSync( + new URL(`../docs/maintainers/${specification.base.file}`, import.meta.url) + ); + const baseRevision = JSON.parse(base.toString('utf8')) as { revision: number }; + expect(specification.revision).toBe(VALUE_CONTRACT_V3_REVISION); + expect(fixtures.revision).toBe(VALUE_CONTRACT_V3_REVISION); + expect(specification.base.revision).toBe(VALUE_CONTRACT_REVISION); + expect(baseRevision.revision).toBe(VALUE_CONTRACT_REVISION); + expect(createHash('sha256').update(base).digest('hex')).toBe(specification.base.sha256); + }); + + it('keeps semantic revision, envelope version, and call policy distinct', () => { + expect(specification.revision).toBe(3); + expect(specification.valueEnvelopeCodecVersion).toBe(2); + expect(specification.callPolicy).toBe('bigint-v2'); + expect(specification.requiredCapability).toBe('exactIntegerDecimalV2'); + expect(specification.rules['integer-exact'].contract).toEqual({ + kind: 'integer-exact', + wire: 'decimal-string-envelope', + decodedAs: 'bigint', + constraint: 'exact-integer', + }); + }); + + it('permits exact integer leaves inside the shared composite shapes', () => { + const nested: ValueContractV3 = { + kind: 'record', + wire: 'json', + decodedAs: 'object', + fields: [ + { + name: 'values', + required: true, + value: { + kind: 'sequence', + wire: 'json', + decodedAs: 'array', + item: specification.rules['integer-exact'].contract, + }, + }, + ], + }; + expect(nested.fields[0]?.value.kind).toBe('sequence'); + }); + + it('fixes the canonical decimal grammar and proof values', () => { + const rule = specification.rules['integer-exact']; + const decimal = new RegExp(rule.decimalGrammar); + expect(rule.maximumDigitsWithoutSign).toBe(4096); + for (const fixture of fixtures.integerCases) { + expect(decimal.test(fixture.logicalDecimal)).toBe(true); + expect(fixture.envelope).toEqual({ + __tywrap__: 'integer', + codecVersion: 2, + encoding: 'decimal', + value: fixture.logicalDecimal, + }); + } + for (const value of fixtures.rejectedDecimals) { + expect(decimal.test(value)).toBe(false); + } + }); +}); From beaf5696d5c1303ff6f8862a2bb2ff79321962cf Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 02:56:02 -0700 Subject: [PATCH 2/4] feat(codegen): compile exact integers behind v3 capability --- docs/maintainers/value-contracts.v3.json | 2 +- src/contracts/value-contract.ts | 20 ++++ src/core/callable-compiler.ts | 92 ++++++++++++++--- src/core/generator.ts | 123 +++++++++++++++++++++-- src/runtime/validators.ts | 10 +- src/types/index.ts | 6 +- test/callable-compiler.test.ts | 91 +++++++++++++++++ test/runtime_return_validation.test.ts | 19 ++++ test/value_contracts_v3.test.ts | 4 +- 9 files changed, 335 insertions(+), 32 deletions(-) diff --git a/docs/maintainers/value-contracts.v3.json b/docs/maintainers/value-contracts.v3.json index 014a2f39..169add95 100644 --- a/docs/maintainers/value-contracts.v3.json +++ b/docs/maintainers/value-contracts.v3.json @@ -7,7 +7,7 @@ "sha256": "554f84b75126513250a7eb773294347fe4dc42262e4ac52f86e7aa19cc65bb4e" }, "transport": "tywrap/1 value RPC", - "valueEnvelopeCodecVersion": 2, + "exactIntegerEnvelopeCodecVersion": 2, "callPolicy": "bigint-v2", "requiredCapability": "exactIntegerDecimalV2", "rules": { diff --git a/src/contracts/value-contract.ts b/src/contracts/value-contract.ts index 63eb1db4..75c65aad 100644 --- a/src/contracts/value-contract.ts +++ b/src/contracts/value-contract.ts @@ -100,6 +100,26 @@ export interface ValueContractField { readonly required: boolean; } +export function containsExactInteger(value: ValueContractV3): boolean { + switch (value.kind) { + case 'integer-exact': + return true; + case 'sequence': + return containsExactInteger(value.item); + case 'tuple': + return value.items.some(containsExactInteger); + case 'union': + return value.options.some(containsExactInteger); + case 'record': + return ( + value.fields.some(field => containsExactInteger(field.value)) || + (value.additionalValues !== undefined && containsExactInteger(value.additionalValues)) + ); + default: + return false; + } +} + export function isSafeJsonInteger(value: number): boolean { return Number.isInteger(value) && Math.abs(value) <= MAX_SAFE_JSON_INTEGER; } diff --git a/src/core/callable-compiler.ts b/src/core/callable-compiler.ts index 9a0d6726..ebf637ea 100644 --- a/src/core/callable-compiler.ts +++ b/src/core/callable-compiler.ts @@ -7,8 +7,11 @@ import { VALUE_CONTRACT_REVISION, - type ValueContract, + VALUE_CONTRACT_V3_REVISION, + containsExactInteger, type ValueContractField, + type ValueContractRevision, + type ValueContractV3, } from '../contracts/value-contract.js'; import type { GeneratedCode, @@ -29,7 +32,8 @@ export type CallableCapability = | 'scientific-ndarray' | 'scientific-torch' | 'coroutine-execution' - | 'dataclass-adapter'; + | 'dataclass-adapter' + | 'exact-integer-adapter'; export interface CapabilityDescription { name: CallableCapability; @@ -51,7 +55,7 @@ export interface ValueConversionRequest { export interface SupportedValueResolution { status: 'supported'; - value: ValueContract; + value: ValueContractV3; } export interface UnsupportedValueResolution { @@ -76,7 +80,7 @@ export type ValueResolution = * The compiler does not infer wire behavior from a TypeScript spelling. */ export interface ValueConversionDescription { - revision: typeof VALUE_CONTRACT_REVISION; + revision: ValueContractRevision; resolve(request: ValueConversionRequest): ValueResolution; } @@ -170,6 +174,11 @@ export const DEFAULT_CALLABLE_CAPABILITIES: readonly CapabilityDescription[] = [ guidance: 'Use a TypedDict or an explicit record adapter until #339 defines dataclass conversion.', }, + { + name: 'exact-integer-adapter', + available: false, + guidance: 'Bind a revision-3 exact integer provider before generating this callable.', + }, ]; function leafName(type: PythonType): string | null { @@ -197,10 +206,14 @@ function firstUnresolvedChild( return resolutions.find(resolution => resolution.status !== 'supported') ?? null; } +function rootWireKinds(value: ValueContractV3): ValueContractV3['kind'][] { + return value.kind === 'union' ? value.options.flatMap(rootWireKinds) : [value.kind]; +} + function resolveSequence( item: PythonType, request: ValueConversionRequest, - conversion: ValueConversionDescription + conversion: Pick ): ValueResolution { const child = conversion.resolve({ ...request, @@ -220,7 +233,7 @@ function resolveSequence( function resolveRecord( valueType: PythonType, request: ValueConversionRequest, - conversion: ValueConversionDescription + conversion: Pick ): ValueResolution { const child = conversion.resolve({ ...request, @@ -246,7 +259,7 @@ function resolveRecord( function resolveTuple( items: readonly PythonType[], request: ValueConversionRequest, - conversion: ValueConversionDescription + conversion: Pick ): ValueResolution { if (items.length === 0) { return { @@ -289,7 +302,7 @@ function resolveTuple( function resolveUnion( options: readonly PythonType[], request: ValueConversionRequest, - conversion: ValueConversionDescription + conversion: Pick ): ValueResolution { if (options.length < 2 || options.length > 32) { return { @@ -320,6 +333,14 @@ function resolveUnion( guidance: 'Provide two explicit value alternatives.', }; } + const wireKinds = values.flatMap(entry => rootWireKinds(entry.value)); + if (wireKinds.includes('integer-exact') && wireKinds.includes('record')) { + return { + status: 'unsupported', + reason: 'An exact integer tag can overlap a record wire value.', + guidance: 'Use a disjoint tagged record instead of this union.', + }; + } return { status: 'supported', value: { @@ -393,9 +414,9 @@ export const DEFAULT_VALUE_CONVERSION: ValueConversionDescription = { revision: VALUE_CONTRACT_REVISION, resolve(request): ValueResolution { const { logicalType: type } = request; - const nestedConversion: ValueConversionDescription = request.resolveNested - ? { revision: VALUE_CONTRACT_REVISION, resolve: request.resolveNested } - : DEFAULT_VALUE_CONVERSION; + const nestedConversion: Pick = { + resolve: request.resolveNested ?? DEFAULT_VALUE_CONVERSION.resolve, + }; if ((request.depth ?? 0) > 64) { return { status: 'unsupported', @@ -526,10 +547,35 @@ export const DEFAULT_VALUE_CONVERSION: ValueConversionDescription = { }, }; -function capabilityNamesFor(value: ValueContract): CallableCapability[] { +/** Explicit revision-3 mode uses the revision-2 rules for every other value. */ +export const EXACT_INTEGER_VALUE_CONVERSION: ValueConversionDescription = { + revision: VALUE_CONTRACT_V3_REVISION, + resolve(request): ValueResolution { + if (request.logicalType.kind === 'primitive' && request.logicalType.name === 'int') { + return { + status: 'supported', + value: { + kind: 'integer-exact', + wire: 'decimal-string-envelope', + decodedAs: 'bigint', + constraint: 'exact-integer', + }, + }; + } + return DEFAULT_VALUE_CONVERSION.resolve({ + ...request, + resolveNested: request.resolveNested ?? EXACT_INTEGER_VALUE_CONVERSION.resolve, + }); + }, +}; + +function capabilityNamesFor(value: ValueContractV3): CallableCapability[] { const names = new Set(['value-rpc', 'return-validation']); - const collect = (current: ValueContract): void => { + const collect = (current: ValueContractV3): void => { switch (current.kind) { + case 'integer-exact': + names.add('exact-integer-adapter'); + break; case 'sequence': collect(current.item); break; @@ -613,7 +659,7 @@ function outputType(value: ResolvedCallableValue): PythonType { return value.resolution.status === 'supported' ? value.logicalType : UNKNOWN_TYPE; } -function valuesMayOverlap(left: ValueContract, right: ValueContract): boolean { +function valuesMayOverlap(left: ValueContractV3, right: ValueContractV3): boolean { if (left.kind === 'unsupported' || right.kind === 'unsupported') { return true; } @@ -809,6 +855,14 @@ function resolveCallable( ]; const requiredCapabilities = new Set(); for (const value of values) { + if (value.resolution.status === 'supported' && containsExactInteger(value.resolution.value)) { + if (conversion.revision !== VALUE_CONTRACT_V3_REVISION) { + throw new Error(`${value.path}: exact integers require value contract revision 3.`); + } + if (capabilities.get('exact-integer-adapter')?.available !== true) { + throw new Error(`${value.path}: exact-integer-adapter is unavailable.`); + } + } const diagnostic = diagnosticForResolution(value.resolution, value.path); if (diagnostic) { diagnostics.push(diagnostic); @@ -913,7 +967,7 @@ function resolveCallable( })), returnType: overloadResults[index] ? outputType(overloadResults[index]) : overload.returnType, })); - const supportedValue = (value: ResolvedCallableValue): ValueContract | undefined => + const supportedValue = (value: ResolvedCallableValue): ValueContractV3 | undefined => value.resolution.status === 'supported' ? value.resolution.value : undefined; const resolvedFunction: PythonFunction = { ...func, @@ -961,6 +1015,12 @@ export function compileContract( ir: ValidatedIrContract, options: CompileContractOptions ): CompiledContract { + if ( + options.conversion.revision !== VALUE_CONTRACT_REVISION && + options.conversion.revision !== VALUE_CONTRACT_V3_REVISION + ) { + throw new Error(`Unsupported value contract revision ${options.conversion.revision}.`); + } if (ir.module !== options.module.name) { throw new Error( `Compiled module ${options.module.name} does not match IR module ${ir.module}.` @@ -1025,7 +1085,7 @@ export function compileContract( guidance: 'Flatten the value or provide a bounded adapter.', }; } - const fields: ValueContractField[] = []; + const fields: ValueContractField[] = []; for (const property of typedDict.properties) { const field = conversion.resolve({ ...request, diff --git a/src/core/generator.ts b/src/core/generator.ts index 41cddf4e..493918cf 100644 --- a/src/core/generator.ts +++ b/src/core/generator.ts @@ -13,7 +13,7 @@ import type { GeneratedCode, TypescriptType, } from '../types/index.js'; -import type { ValueContract } from '../contracts/value-contract.js'; +import { containsExactInteger, type ValueContractV3 } from '../contracts/value-contract.js'; import type { OverloadReturnSchema, ReturnSchema } from '../runtime/validators.js'; import { globalCache } from '../utils/cache.js'; @@ -41,7 +41,7 @@ interface GenericRenderContext { type ReturnDefinitionNames = ReadonlySet & { readonly moduleName?: string }; -function valueContractToReturnSchema(value: ValueContract): ReturnSchema { +function valueContractToReturnSchema(value: ValueContractV3): ReturnSchema { switch (value.kind) { case 'null': return { kind: 'primitive', type: 'null' }; @@ -49,6 +49,8 @@ function valueContractToReturnSchema(value: ValueContract): ReturnSchema { return { kind: 'primitive', type: 'boolean' }; case 'integer': return { kind: 'primitive', type: 'number', constraint: value.constraint }; + case 'integer-exact': + return { kind: 'primitive', type: 'bigint' }; case 'float': return { kind: 'primitive', type: 'number', constraint: value.constraint }; case 'string': @@ -83,6 +85,77 @@ function valueContractToReturnSchema(value: ValueContract): ReturnSchema { } } +function valueContractToTsType(value: ValueContractV3): string { + switch (value.kind) { + case 'null': + return 'null'; + case 'boolean': + return 'boolean'; + case 'integer': + case 'float': + return 'number'; + case 'integer-exact': + return 'bigint'; + case 'string': + return 'string'; + case 'bytes': + return 'Uint8Array'; + case 'sequence': { + const item = valueContractToTsType(value.item); + return `${value.item.kind === 'union' ? `(${item})` : item}[]`; + } + case 'tuple': + return `[${value.items.map(valueContractToTsType).join(', ')}]`; + case 'union': + return value.options.map(valueContractToTsType).join(' | '); + case 'record': { + const fields = value.fields + .map( + field => + `${JSON.stringify(field.name)}${field.required ? '' : '?'}: ${valueContractToTsType(field.value)};` + ) + .join(' '); + const record = `{ ${fields} }`; + if (!value.additionalValues) { + return record; + } + const additional = `Record`; + return value.fields.length === 0 ? additional : `(${record} & ${additional})`; + } + case 'ndarray-float16': + return '__tywrapFloat16Value'; + case 'torch-float16': + return '__tywrapFloat16Tensor'; + case 'unsupported': + return 'unknown'; + } +} + +function containsScientificValue( + value: ValueContractV3, + kind: 'ndarray-float16' | 'torch-float16' +): boolean { + if (value.kind === kind) { + return true; + } + switch (value.kind) { + case 'sequence': + return containsScientificValue(value.item, kind); + case 'tuple': + return value.items.some(item => containsScientificValue(item, kind)); + case 'union': + return value.options.some(option => containsScientificValue(option, kind)); + case 'record': + return ( + value.fields.some(field => containsScientificValue(field.value, kind)) || + (value.additionalValues !== undefined && + containsScientificValue(value.additionalValues, kind)) + ); + default: + return false; + } +} + export interface CodeGeneratorOptions { /** Reports a generated annotation that cannot be represented by emitted declarations. */ onTypeDegrade?: (typeName: string) => void; @@ -449,10 +522,13 @@ export class CodeGenerator { } private resolvedReturnType( - contract: ValueContract | undefined, + contract: ValueContractV3 | undefined, logicalType: PythonType, ctx: GenericRenderContext ): string { + if (contract && containsExactInteger(contract)) { + return valueContractToTsType(contract); + } if (contract?.kind === 'ndarray-float16') { return '__tywrapFloat16Value'; } @@ -786,8 +862,28 @@ export class CodeGenerator { ); const typeParamDecl = genericContext.declaration; - const tsTypeForValue = (p: Parameter): string => - this.typeToTsFromPython(p.type, genericContext, 'value'); + const parameterContracts = new WeakMap(); + func.parameters.forEach((parameter, index) => { + const value = func.callableContract?.parameterValues[index]; + if (value) { + parameterContracts.set(parameter, value); + } + }); + func.overloads?.forEach((overload, overloadIndex) => { + overload.parameters.forEach((parameter, parameterIndex) => { + const value = + func.callableContract?.overloads[overloadIndex]?.parameterValues[parameterIndex]; + if (value) { + parameterContracts.set(parameter, value); + } + }); + }); + const tsTypeForValue = (p: Parameter): string => { + const value = parameterContracts.get(p); + return value && containsExactInteger(value) + ? valueContractToTsType(value) + : this.typeToTsFromPython(p.type, genericContext, 'value'); + }; const kwargsType = (() => { if (!needsKwargsParam) { @@ -1501,13 +1597,22 @@ ${migrationNote}${declarationMethodsSection} ) ), ]; - const resolvedReturns = emittedCallables.flatMap(func => [ + const resolvedValues = emittedCallables.flatMap(func => [ + ...(func.callableContract?.parameterValues ?? []), func.callableContract?.returnValue, - ...(func.callableContract?.overloads.map(overload => overload.returnValue) ?? []), + ...(func.callableContract?.overloads.flatMap(overload => [ + ...overload.parameterValues, + overload.returnValue, + ]) ?? []), ]); - const needsTorchFloat16 = resolvedReturns.some(value => value?.kind === 'torch-float16'); + const needsTorchFloat16 = resolvedValues.some( + value => value !== undefined && containsScientificValue(value, 'torch-float16') + ); const needsFloat16 = - needsTorchFloat16 || resolvedReturns.some(value => value?.kind === 'ndarray-float16'); + needsTorchFloat16 || + resolvedValues.some( + value => value !== undefined && containsScientificValue(value, 'ndarray-float16') + ); const scientificTypes = needsFloat16 ? `type __tywrapFloat16Value = number | __tywrapFloat16Value[];\n${ needsTorchFloat16 diff --git a/src/runtime/validators.ts b/src/runtime/validators.ts index cabbafda..c0b56bbb 100644 --- a/src/runtime/validators.ts +++ b/src/runtime/validators.ts @@ -26,7 +26,15 @@ export type ReturnSchema = | { kind: 'any' } | { kind: 'primitive'; - type: 'number' | 'string' | 'boolean' | 'null' | 'undefined' | 'Uint8Array' | 'object'; + type: + | 'number' + | 'bigint' + | 'string' + | 'boolean' + | 'null' + | 'undefined' + | 'Uint8Array' + | 'object'; constraint?: 'safe-integer' | 'finite'; } | { kind: 'literal'; value: string | number | boolean | null } diff --git a/src/types/index.ts b/src/types/index.ts index 90546f36..b717b0fb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,7 +2,7 @@ * Core type definitions for tywrap */ -import type { ValueContract } from '../contracts/value-contract.js'; +import type { ValueContractV3 } from '../contracts/value-contract.js'; import type { DecodedProvenance } from '../runtime/decoded-provenance.js'; export interface PythonModule { @@ -72,8 +72,8 @@ export interface PythonFunctionOverload { /** A resolved value contract for a callable signature. */ export interface ResolvedCallableSignatureContract { - parameterValues: readonly (ValueContract | undefined)[]; - returnValue?: ValueContract; + parameterValues: readonly (ValueContractV3 | undefined)[]; + returnValue?: ValueContractV3; /** Original annotation used for best-effort validation when no value contract resolves. */ returnValidationType?: PythonType; } diff --git a/test/callable-compiler.test.ts b/test/callable-compiler.test.ts index e154674f..f255e745 100644 --- a/test/callable-compiler.test.ts +++ b/test/callable-compiler.test.ts @@ -9,6 +9,7 @@ import { compileContract, DEFAULT_CALLABLE_CAPABILITIES, DEFAULT_VALUE_CONVERSION, + EXACT_INTEGER_VALUE_CONVERSION, } from '../src/core/callable-compiler.js'; import { CodeGenerator } from '../src/core/generator.js'; import { parseAnnotationToPythonType } from '../src/core/annotation-parser.js'; @@ -1701,6 +1702,96 @@ describe('compileContract', () => { }); }); + it('uses revision 3 for explicit nested bigint callables only', () => { + const source = rawIr.functions[1]!; + const ir = validateIrContract( + { + ...rawIr, + functions: [ + { + ...source, + name: 'combine_exact', + qualname: 'fixture.combine_exact', + parameters: [ + { name: 'value', kind: 'POSITIONAL_OR_KEYWORD', annotation: 'int', default: false }, + { + name: 'nested', + kind: 'POSITIONAL_OR_KEYWORD', + annotation: 'list[int]', + default: false, + }, + { name: 'flag', kind: 'POSITIONAL_OR_KEYWORD', annotation: 'bool', default: false }, + { name: 'ratio', kind: 'POSITIONAL_OR_KEYWORD', annotation: 'float', default: false }, + ], + returns: 'int', + }, + ], + }, + 'exact integer callable contract' + ); + expect(ir.ok).toBe(true); + if (!ir.ok) { + return; + } + const module = { + ...moduleModel, + functions: [{ ...moduleModel.functions[1]!, name: 'combine_exact' }], + classes: [], + }; + const options = { + module, + generator: new CodeGenerator(), + conversion: EXACT_INTEGER_VALUE_CONVERSION, + capabilities: DEFAULT_CALLABLE_CAPABILITIES, + }; + expect(() => compileContract(ir.contract, options)).toThrow( + /exact-integer-adapter is unavailable/ + ); + const capabilities = DEFAULT_CALLABLE_CAPABILITIES.map(capability => + capability.name === 'exact-integer-adapter' ? { ...capability, available: true } : capability + ); + const exact = compileContract(ir.contract, { ...options, capabilities }); + expect(exact.callables[0]?.requiredCapabilities).toContain('exact-integer-adapter'); + expect(exact.callables[0]?.parameters[1]?.resolution).toMatchObject({ + status: 'supported', + value: { kind: 'sequence', item: { kind: 'integer-exact' } }, + }); + expect(exact.generated.declaration).toContain('value: bigint'); + expect(exact.generated.declaration).toContain('nested: bigint[]'); + expect(exact.generated.declaration).toContain('flag: boolean'); + expect(exact.generated.declaration).toContain('ratio: number'); + expect(exact.generated.declaration).toContain('Promise'); + expect(exact.generated.typescript).toContain('"kind":"primitive","type":"bigint"'); + + const defaultContract = compileContract(ir.contract, { + ...options, + conversion: DEFAULT_VALUE_CONVERSION, + }); + expect(defaultContract.generated.declaration).toContain('value: number'); + expect(defaultContract.generated.declaration).toContain('nested: number[]'); + expect(defaultContract.generated.declaration).toContain('Promise'); + }); + + it('keeps exact integer tags disjoint from record union options', () => { + expect( + EXACT_INTEGER_VALUE_CONVERSION.resolve({ + direction: 'output', + path: '$.returns', + logicalType: parseAnnotationToPythonType('int | dict[str, str]'), + }) + ).toMatchObject({ + status: 'unsupported', + reason: 'An exact integer tag can overlap a record wire value.', + }); + expect( + EXACT_INTEGER_VALUE_CONVERSION.resolve({ + direction: 'output', + path: '$.returns', + logicalType: parseAnnotationToPythonType('int | bool'), + }) + ).toMatchObject({ status: 'supported', value: { kind: 'union' } }); + }); + it('requires returned dataclass fields even when their constructor has defaults', () => { const point = rawIr.classes[0]!; const ir = validateIrContract( diff --git a/test/runtime_return_validation.test.ts b/test/runtime_return_validation.test.ts index ca35a82c..7a4594f2 100644 --- a/test/runtime_return_validation.test.ts +++ b/test/runtime_return_validation.test.ts @@ -41,6 +41,25 @@ describe('generated return validators', () => { expect(() => validator({ count: Number.MAX_SAFE_INTEGER + 1 })).toThrow(BridgeValidationError); }); + it('requires bigint at each exact integer position', () => { + const validator = createReturnValidator( + { + kind: 'record', + fields: { + values: { + schema: { kind: 'array', element: { kind: 'primitive', type: 'bigint' } }, + }, + }, + }, + 'fixture.exactValues' + ); + expect(validator({ values: [1n, 18446744073709551617n] })).toEqual({ + values: [1n, 18446744073709551617n], + }); + expect(() => validator({ values: [1, 2n] })).toThrow(BridgeValidationError); + expect(() => validator({ values: ['1'] })).toThrow(/expected bigint, received string/); + }); + it('requires per-call provenance for a scalar float16 ndarray', () => { const validate = createReturnValidator( { kind: 'marker', marker: 'ndarray', dims: 0, dtype: 'float16' }, diff --git a/test/value_contracts_v3.test.ts b/test/value_contracts_v3.test.ts index 70db9b6e..046f1cee 100644 --- a/test/value_contracts_v3.test.ts +++ b/test/value_contracts_v3.test.ts @@ -13,7 +13,7 @@ const specification = JSON.parse( ) as { revision: number; base: { file: string; revision: number; sha256: string }; - valueEnvelopeCodecVersion: number; + exactIntegerEnvelopeCodecVersion: number; callPolicy: string; requiredCapability: string; rules: { @@ -54,7 +54,7 @@ describe('exact integer value policy', () => { it('keeps semantic revision, envelope version, and call policy distinct', () => { expect(specification.revision).toBe(3); - expect(specification.valueEnvelopeCodecVersion).toBe(2); + expect(specification.exactIntegerEnvelopeCodecVersion).toBe(2); expect(specification.callPolicy).toBe('bigint-v2'); expect(specification.requiredCapability).toBe('exactIntegerDecimalV2'); expect(specification.rules['integer-exact'].contract).toEqual({ From a51301a73d8491a18e455863a79bc72300522d66 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 02:58:48 -0700 Subject: [PATCH 3/4] fix(contracts): make recursive revisions typecheck --- src/contracts/value-contract.ts | 76 +++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/src/contracts/value-contract.ts b/src/contracts/value-contract.ts index 75c65aad..b8b8c207 100644 --- a/src/contracts/value-contract.ts +++ b/src/contracts/value-contract.ts @@ -51,35 +51,47 @@ type CommonValueContractLeaf = readonly guidance: string; }; -type CompositeValueContract = - | { - readonly kind: 'sequence'; - readonly wire: 'json'; - readonly decodedAs: 'array'; - readonly item: TValue; - } - | { - readonly kind: 'tuple'; - readonly wire: 'json'; - readonly decodedAs: 'array'; - readonly items: readonly TValue[]; - } - | { - readonly kind: 'union'; - readonly wire: 'selected-option'; - readonly decodedAs: 'selected-option'; - readonly options: readonly [TValue, TValue, ...TValue[]]; - } - | { - readonly kind: 'record'; - readonly wire: 'json'; - readonly decodedAs: 'object'; - readonly fields: readonly ValueContractField[]; - readonly additionalValues?: TValue; - }; +interface SequenceContract { + readonly kind: 'sequence'; + readonly wire: 'json'; + readonly decodedAs: 'array'; + readonly item: TValue; +} + +interface TupleContract { + readonly kind: 'tuple'; + readonly wire: 'json'; + readonly decodedAs: 'array'; + readonly items: readonly TValue[]; +} + +interface UnionContract { + readonly kind: 'union'; + readonly wire: 'selected-option'; + readonly decodedAs: 'selected-option'; + readonly options: readonly [TValue, TValue, ...TValue[]]; +} + +interface RecordContract { + readonly kind: 'record'; + readonly wire: 'json'; + readonly decodedAs: 'object'; + readonly fields: readonly ValueContractField[]; + readonly additionalValues?: TValue; +} + +interface V2SequenceContract extends SequenceContract {} +interface V2TupleContract extends TupleContract {} +interface V2UnionContract extends UnionContract {} +interface V2RecordContract extends RecordContract {} /** Revision 2 remains the default safe-number value contract. */ -export type ValueContract = CommonValueContractLeaf | CompositeValueContract; +export type ValueContract = + | CommonValueContractLeaf + | V2SequenceContract + | V2TupleContract + | V2UnionContract + | V2RecordContract; export interface ExactIntegerValueContract { readonly kind: 'integer-exact'; @@ -88,11 +100,19 @@ export interface ExactIntegerValueContract { readonly constraint: 'exact-integer'; } +interface V3SequenceContract extends SequenceContract {} +interface V3TupleContract extends TupleContract {} +interface V3UnionContract extends UnionContract {} +interface V3RecordContract extends RecordContract {} + /** Revision 3 adds exact integers without changing revision 2. */ export type ValueContractV3 = | CommonValueContractLeaf | ExactIntegerValueContract - | CompositeValueContract; + | V3SequenceContract + | V3TupleContract + | V3UnionContract + | V3RecordContract; export interface ValueContractField { readonly name: string; From 1862f3ed8e600e8586acf517499f52b9863a3d5e Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 03:15:54 -0700 Subject: [PATCH 4/4] fix(codegen): type exact method and variadic inputs --- docs/maintainers/value-contracts.md | 56 +++--- docs/maintainers/value-contracts.v3.json | 1 + src/core/callable-compiler.ts | 58 +++++- src/core/generator.ts | 120 +++++++++--- test/callable-compiler.test.ts | 223 +++++++++++++++++++++++ test/runtime_return_validation.test.ts | 4 +- test/value_contracts_v3.test.ts | 8 +- 7 files changed, 410 insertions(+), 60 deletions(-) diff --git a/docs/maintainers/value-contracts.md b/docs/maintainers/value-contracts.md index 20c8293f..b0e4481d 100644 --- a/docs/maintainers/value-contracts.md +++ b/docs/maintainers/value-contracts.md @@ -46,35 +46,35 @@ These checks do not walk Arrow payloads again for schema validation. ## Exact bigint extension for #337 -This extension is a design sketch. The 0.11 policy still rejects unsafe plain -Python integers. - -Choose an explicit callable option, `integerMode: 'bigint'`. When enabled, map -Python `int` to TypeScript `bigint` for both parameters and returns. The return -validator must require `typeof value === 'bigint'`, including every record and -sequence member. Keep `bool` separate. Calls without the option keep `number` -and the safe range. - -Add a version 2 integer value envelope with a canonical signed decimal string. A -sample value is +The [revision-3 policy](value-contracts.v3.json) is a bounded prototype. It pins +the revision-2 policy and adds one `integer-exact` leaf. Revision 2 remains the +default safe-number policy. No default bridge supports bigint calls yet. + +The internal `EXACT_INTEGER_VALUE_CONVERSION` selects revision 3 for a fixture +module. It maps Python `int` to TypeScript `bigint` in parameters and returns. +The generated validator requires `bigint` at nested integer nodes. Python `bool` +and finite `float` keep their revision-2 rules. The compiler requires an +explicit `exact-integer-adapter` capability. It is unavailable by default. The +prototype has no public per-callable option for mixed modules. + +The opted call uses a version-2 integer envelope with a canonical signed decimal +string. A sample value is `{"__tywrap__":"integer","codecVersion":2,"encoding":"decimal","value":"18446744073709551617"}`. -The request encoder and Python request decoder must validate the same grammar -and digit limit. The response encoder and TypeScript decoder must do the same. -Use `BigInt` only after validation. Keep Arrow int64 columns on their current -Arrow path. - -The bridge must advertise exact integer support during capability negotiation. A -generated bigint wrapper must reject an older bridge before its first call. The -new bridge must emit version 2 integer envelopes only when the call opts in. An -older client therefore continues to receive the 0.11 safe integer policy. -Unknown integer envelopes, mixed versions, oversized strings, and malformed -decimal values must fail with a path. Do not guess a conversion. - -Acceptance evidence still needed: a bounded prototype must round trip positive -and negative values beyond 64 bits through nested records and arrays. It must -test bigint inputs, malformed tags, payload limits, and both mixed-version -directions. An independent review must approve the wire grammar, capability -rule, generated types, and migration cost before #337 can close. +The companion test adapter must check the grammar and 4096-digit limit in both +directions. It must use `BigInt` only after validation. Arrow int64 keeps its +current path. Contract revision 3, envelope `codecVersion: 2`, per-call policy +`bigint-v2`, and RPC protocol `tywrap/1` are different version labels. + +The bound test provider must see `exactIntegerDecimalV2` in bridge metadata +before it sends a tagged request. It sets `params.valuePolicy.integer` to +`bigint-v2`. Calls without that policy keep revision-2 behavior. The production +bridge does not negotiate this capability. Unknown versions and malformed +decimal values must fail with a path. + +The [revision-3 fixtures](value-contract-fixtures.v3.json) record exact values, +negative zero, and migration cost. The generated proof still needs integrated CI +and independent review before #337 can close. A prototype does not enable bigint +by default or authorize a release. ## Dataclass return extension for #339 diff --git a/docs/maintainers/value-contracts.v3.json b/docs/maintainers/value-contracts.v3.json index 169add95..e7b5c0ae 100644 --- a/docs/maintainers/value-contracts.v3.json +++ b/docs/maintainers/value-contracts.v3.json @@ -4,6 +4,7 @@ "base": { "file": "value-contracts.v2.json", "revision": 2, + "hashCanonicalization": "UTF-8 with LF line endings", "sha256": "554f84b75126513250a7eb773294347fe4dc42262e4ac52f86e7aa19cc65bb4e" }, "transport": "tywrap/1 value RPC", diff --git a/src/core/callable-compiler.ts b/src/core/callable-compiler.ts index ebf637ea..c5f207a1 100644 --- a/src/core/callable-compiler.ts +++ b/src/core/callable-compiler.ts @@ -409,6 +409,19 @@ function resolveTorchFloat16(): ValueResolution { }; } +function depthLimitResolution( + depth: number | undefined, + revision: ValueContractRevision +): UnsupportedValueResolution | null { + return (depth ?? 0) > 64 + ? { + status: 'unsupported', + reason: `Revision ${revision} limits value contracts to 64 nested nodes.`, + guidance: 'Flatten the value or provide a bounded adapter.', + } + : null; +} + /** The conversion set that #335 may use with the frozen value-contract revision. */ export const DEFAULT_VALUE_CONVERSION: ValueConversionDescription = { revision: VALUE_CONTRACT_REVISION, @@ -417,12 +430,9 @@ export const DEFAULT_VALUE_CONVERSION: ValueConversionDescription = { const nestedConversion: Pick = { resolve: request.resolveNested ?? DEFAULT_VALUE_CONVERSION.resolve, }; - if ((request.depth ?? 0) > 64) { - return { - status: 'unsupported', - reason: 'Revision 2 limits value contracts to 64 nested nodes.', - guidance: 'Flatten the value or provide a bounded adapter.', - }; + const depthFailure = depthLimitResolution(request.depth, VALUE_CONTRACT_REVISION); + if (depthFailure) { + return depthFailure; } switch (type.kind) { case 'primitive': @@ -551,6 +561,10 @@ export const DEFAULT_VALUE_CONVERSION: ValueConversionDescription = { export const EXACT_INTEGER_VALUE_CONVERSION: ValueConversionDescription = { revision: VALUE_CONTRACT_V3_REVISION, resolve(request): ValueResolution { + const depthFailure = depthLimitResolution(request.depth, VALUE_CONTRACT_V3_REVISION); + if (depthFailure) { + return depthFailure; + } if (request.logicalType.kind === 'primitive' && request.logicalType.name === 'int') { return { status: 'supported', @@ -853,6 +867,34 @@ function resolveCallable( ...visibleOverloadParameters.flat(), ...overloadResults, ]; + const rejectMixedExactKwargs = ( + source: readonly Parameter[], + resolved: readonly ResolvedCallableValue[], + sourcePath: string + ): void => { + if (!source.some(parameter => parameter.keywordOnly)) { + return; + } + const index = source.findIndex(parameter => parameter.kwArgs); + const resolution = resolved[index]?.resolution; + if ( + index >= 0 && + resolution?.status === 'supported' && + containsExactInteger(resolution.value) + ) { + throw new Error( + `${sourcePath}.parameters[${index}]: exact **kwargs cannot share a signature with named keyword-only parameters.` + ); + } + }; + rejectMixedExactKwargs(func.parameters, parameters, path); + (func.overloads ?? []).forEach((overload, index) => + rejectMixedExactKwargs( + overload.parameters, + overloadParameters[index] ?? [], + `${path}.overloads[${index}]` + ) + ); const requiredCapabilities = new Set(); for (const value of values) { if (value.resolution.status === 'supported' && containsExactInteger(value.resolution.value)) { @@ -1081,7 +1123,7 @@ export function compileContract( if ((request.depth ?? 0) >= 64) { return { status: 'unsupported', - reason: 'Revision 2 limits value contracts to 64 nested nodes.', + reason: `Revision ${conversion.revision} limits value contracts to 64 nested nodes.`, guidance: 'Flatten the value or provide a bounded adapter.', }; } @@ -1113,7 +1155,7 @@ export function compileContract( if ((request.depth ?? 0) >= 64) { return { status: 'unsupported', - reason: 'Revision 2 limits value contracts to 64 nested nodes.', + reason: `Revision ${conversion.revision} limits value contracts to 64 nested nodes.`, guidance: 'Flatten the value or provide a bounded adapter.', }; } diff --git a/src/core/generator.ts b/src/core/generator.ts index 493918cf..5b1d6912 100644 --- a/src/core/generator.ts +++ b/src/core/generator.ts @@ -538,6 +538,16 @@ export class CodeGenerator { return this.typeToTsFromPython(logicalType, ctx, 'return'); } + private resolvedParameterType( + contract: ValueContractV3 | undefined, + logicalType: PythonType, + ctx: GenericRenderContext + ): string { + return contract && containsExactInteger(contract) + ? valueContractToTsType(contract) + : this.typeToTsFromPython(logicalType, ctx, 'value'); + } + private isLocalTypeIdentity( type: { name: string; module?: string }, ctx: GenericRenderContext @@ -841,6 +851,7 @@ export class CodeGenerator { const keywordOnlyParams = filteredParams.filter(p => p.keywordOnly); const positionalOnlyNames = filteredParams.filter(p => p.positionalOnly).map(p => p.name); const hasVarKwArgs = filteredParams.some(p => p.kwArgs); + const varKwArgsParam = filteredParams.find(p => p.kwArgs); const needsKwargsParam = keywordOnlyParams.length > 0 || hasVarKwArgs; const varArgsParam = filteredParams.find(p => p.varArgs); @@ -880,10 +891,21 @@ export class CodeGenerator { }); const tsTypeForValue = (p: Parameter): string => { const value = parameterContracts.get(p); - return value && containsExactInteger(value) - ? valueContractToTsType(value) - : this.typeToTsFromPython(p.type, genericContext, 'value'); + return this.resolvedParameterType(value, p.type, genericContext); + }; + const hasExactParameter = (parameter: Parameter): boolean => { + const value = parameterContracts.get(parameter); + return value !== undefined && containsExactInteger(value); }; + const varArgsType = (parameter: Parameter): string => { + if (!hasExactParameter(parameter)) { + return 'unknown[]'; + } + const item = tsTypeForValue(parameter); + return `${parameterContracts.get(parameter)?.kind === 'union' ? `(${item})` : item}[]`; + }; + const varKwargsType = (parameter: Parameter): string => + hasExactParameter(parameter) ? tsTypeForValue(parameter) : 'unknown'; const kwargsType = (() => { if (!needsKwargsParam) { @@ -893,13 +915,15 @@ export class CodeGenerator { return 'Record'; } if (keywordOnlyParams.length === 0 && hasVarKwArgs) { - return 'Record'; + return `Record`; } const props = keywordOnlyParams .map(p => `${JSON.stringify(p.name)}${p.optional ? '?' : ''}: ${tsTypeForValue(p)};`) .join(' '); const obj = `{ ${props} }`; - return hasVarKwArgs ? `(${obj} & Record)` : obj; + return hasVarKwArgs + ? `(${obj} & Record)` + : obj; })(); const renderPositionalParam = ( @@ -916,11 +940,12 @@ export class CodeGenerator { return null; } const pname = this.escapeIdentifier(varArgsParam.name); + const values = hasDeclaredOverloads ? 'unknown[]' : varArgsType(varArgsParam); if (!needsVarArgsArray) { - return `...${pname}: unknown[]`; + return `...${pname}: ${values}`; } const opt = forceRequired ? '' : '?'; - return `${pname}${opt}: unknown[]`; + return `${pname}${opt}: ${values}`; }; const renderKwargsParam = (forceRequired = false): string | null => { @@ -970,6 +995,7 @@ export class CodeGenerator { const overloadKeywordOnly = overloadParams.filter(parameter => parameter.keywordOnly); const overloadVarArgs = overloadParams.find(parameter => parameter.varArgs); const overloadHasVarKwArgs = overloadParams.some(parameter => parameter.kwArgs); + const overloadVarKwArgs = overloadParams.find(parameter => parameter.kwArgs); const overloadNeedsKwargs = overloadKeywordOnly.length > 0 || overloadHasVarKwArgs; const overloadNeedsVarArgsArray = Boolean(overloadVarArgs) && overloadNeedsKwargs; const overloadKwargsType = (() => { @@ -977,7 +1003,7 @@ export class CodeGenerator { return ''; } if (overloadKeywordOnly.length === 0 && overloadHasVarKwArgs) { - return 'Record'; + return `Record`; } const properties = overloadKeywordOnly .map( @@ -986,7 +1012,9 @@ export class CodeGenerator { ) .join(' '); const object = `{ ${properties} }`; - return overloadHasVarKwArgs ? `(${object} & Record)` : object; + return overloadHasVarKwArgs + ? `(${object} & Record)` + : object; })(); const renderPositional = (parameter: Parameter, forceRequired = false): string => `${this.escapeIdentifier(parameter.name)}${!forceRequired && parameter.optional ? '?' : ''}: ${tsTypeForValue(parameter)}`; @@ -995,9 +1023,9 @@ export class CodeGenerator { return null; } if (!overloadNeedsVarArgsArray) { - return `...${this.escapeIdentifier(overloadVarArgs.name)}: unknown[]`; + return `...${this.escapeIdentifier(overloadVarArgs.name)}: ${varArgsType(overloadVarArgs)}`; } - return `${this.escapeIdentifier(overloadVarArgs.name)}${forceRequired ? '' : '?'}: unknown[]`; + return `${this.escapeIdentifier(overloadVarArgs.name)}${forceRequired ? '' : '?'}: ${varArgsType(overloadVarArgs)}`; }; const renderKwargs = (forceRequired = false): string | null => overloadNeedsKwargs ? `kwargs${forceRequired ? '' : '?'}: ${overloadKwargsType}` : null; @@ -1025,9 +1053,13 @@ export class CodeGenerator { const rest: string[] = []; if (overloadVarArgs) { if (overloadNeedsVarArgsArray) { - rest.push(`${this.escapeIdentifier(overloadVarArgs.name)}: unknown[] | undefined`); + rest.push( + `${this.escapeIdentifier(overloadVarArgs.name)}: ${varArgsType(overloadVarArgs)} | undefined` + ); } else { - rest.push(`...${this.escapeIdentifier(overloadVarArgs.name)}: unknown[]`); + rest.push( + `...${this.escapeIdentifier(overloadVarArgs.name)}: ${varArgsType(overloadVarArgs)}` + ); } } const kwargs = renderKwargs(true); @@ -1077,12 +1109,13 @@ export class CodeGenerator { return null; } const pname = this.escapeIdentifier(varArgsParam.name); + const values = varArgsType(varArgsParam); if (!needsVarArgsArray) { - return `...${pname}: unknown[]`; + return `...${pname}: ${values}`; } // In overloads where `kwargs` is required, keep the `args` surrogate parameter required, // but allow `undefined` as a placeholder so callers can omit varargs while still passing kwargs. - return `${pname}: unknown[] | undefined`; + return `${pname}: ${values} | undefined`; })(); if (v) { rest.push(v); @@ -1300,11 +1333,31 @@ ${callPrelude}${guards}${overloadValidator} return ${runtimeGetterIdentifier}() methodOwnGenericContext ); const methodTypeParamDecl = methodOwnGenericContext.declaration; - const methodTsValueType = (p: (typeof fparams)[number]): string => - this.typeToTsFromPython(p.type, methodGenericContext, 'value'); + const methodParameterContracts = new WeakMap(); + method.parameters.forEach((parameter, index) => { + const value = method.callableContract?.parameterValues[index]; + if (value) { + methodParameterContracts.set(parameter, value); + } + }); + const methodTsValueType = (p: Parameter): string => + this.resolvedParameterType(methodParameterContracts.get(p), p.type, methodGenericContext); + const methodVarArgsType = (p: Parameter): string => { + const value = methodParameterContracts.get(p); + if (!value || !containsExactInteger(value)) { + return 'unknown[]'; + } + const item = methodTsValueType(p); + return `${value.kind === 'union' ? `(${item})` : item}[]`; + }; + const methodVarKwargsType = (p: Parameter): string => { + const value = methodParameterContracts.get(p); + return value && containsExactInteger(value) ? methodTsValueType(p) : 'unknown'; + }; const keywordOnlyParams = fparams.filter(p => p.keywordOnly); const positionalOnlyNames = fparams.filter(p => p.positionalOnly).map(p => p.name); const hasVarKwArgs = fparams.some(p => p.kwArgs); + const varKwArgsParam = fparams.find(p => p.kwArgs); const needsKwargsParam = keywordOnlyParams.length > 0 || hasVarKwArgs; const varArgsParam = fparams.find(p => p.varArgs); const needsVarArgsArray = Boolean(varArgsParam) && needsKwargsParam; @@ -1331,13 +1384,15 @@ ${callPrelude}${guards}${overloadValidator} return ${runtimeGetterIdentifier}() return 'Record'; } if (keywordOnlyParams.length === 0 && hasVarKwArgs) { - return 'Record'; + return `Record`; } const props = keywordOnlyParams .map(p => `${JSON.stringify(p.name)}${p.optional ? '?' : ''}: ${methodTsValueType(p)};`) .join(' '); const obj = `{ ${props} }`; - return hasVarKwArgs ? `(${obj} & Record)` : obj; + return hasVarKwArgs + ? `(${obj} & Record)` + : obj; })(); const paramsDeclParts: string[] = []; @@ -1347,7 +1402,9 @@ ${callPrelude}${guards}${overloadValidator} return ${runtimeGetterIdentifier}() if (varArgsParam) { const vname = this.escapeIdentifier(varArgsParam.name); paramsDeclParts.push( - needsVarArgsArray ? `${vname}?: unknown[]` : `...${vname}: unknown[]` + needsVarArgsArray + ? `${vname}?: ${hasDeclaredOverloads ? 'unknown[]' : methodVarArgsType(varArgsParam)}` + : `...${vname}: ${hasDeclaredOverloads ? 'unknown[]' : methodVarArgsType(varArgsParam)}` ); } if (needsKwargsParam) { @@ -1372,6 +1429,25 @@ ${callPrelude}${guards}${overloadValidator} return ${runtimeGetterIdentifier}() { ...method, parameters: fparams, + callableContract: method.callableContract + ? { + ...method.callableContract, + parameterValues: method.callableContract.parameterValues.filter( + (_, index) => + method.parameters[index]?.name !== 'self' && + method.parameters[index]?.name !== 'cls' + ), + overloads: method.callableContract.overloads.map((contract, index) => ({ + ...contract, + parameterValues: contract.parameterValues.filter( + (_, parameterIndex) => + method.overloads?.[index]?.parameters[parameterIndex]?.name !== + 'self' && + method.overloads?.[index]?.parameters[parameterIndex]?.name !== 'cls' + ), + })), + } + : undefined, overloads: method.overloads?.map(overload => ({ ...overload, parameters: overload.parameters.filter( @@ -1399,7 +1475,9 @@ ${callPrelude}${guards}${overloadValidator} return ${runtimeGetterIdentifier}() if (varArgsParam) { const vname = this.escapeIdentifier(varArgsParam.name); rest.push( - needsVarArgsArray ? `${vname}: unknown[] | undefined` : `...${vname}: unknown[]` + needsVarArgsArray + ? `${vname}: ${methodVarArgsType(varArgsParam)} | undefined` + : `...${vname}: ${methodVarArgsType(varArgsParam)}` ); } rest.push(`kwargs: ${kwargsType}`); diff --git a/test/callable-compiler.test.ts b/test/callable-compiler.test.ts index f255e745..40d348d9 100644 --- a/test/callable-compiler.test.ts +++ b/test/callable-compiler.test.ts @@ -1792,6 +1792,229 @@ describe('compileContract', () => { ).toMatchObject({ status: 'supported', value: { kind: 'union' } }); }); + it('keeps exact integer depth bounded before resolving a leaf', () => { + const request = { + direction: 'output' as const, + path: '$.returns', + logicalType: parseAnnotationToPythonType('int'), + }; + expect(EXACT_INTEGER_VALUE_CONVERSION.resolve({ ...request, depth: 64 }).status).toBe( + 'supported' + ); + expect(EXACT_INTEGER_VALUE_CONVERSION.resolve({ ...request, depth: 65 })).toMatchObject({ + status: 'unsupported', + reason: 'Revision 3 limits value contracts to 64 nested nodes.', + }); + }); + + it('uses exact input types in static and class method declarations', () => { + const source = rawIr.functions[1]!; + const value = { + name: 'value', + kind: 'POSITIONAL_OR_KEYWORD', + annotation: 'int', + default: false, + }; + const receiver = { + name: 'cls', + kind: 'POSITIONAL_OR_KEYWORD', + annotation: null, + default: false, + }; + const methods = [ + { + ...source, + name: 'static_echo', + qualname: 'fixture.Box.static_echo', + method_kind: 'static', + parameters: [value], + returns: 'int', + }, + { + ...source, + name: 'class_echo', + qualname: 'fixture.Box.class_echo', + method_kind: 'class', + parameters: [receiver, value], + returns: 'int', + overloads: [ + { parameters: [receiver, value], returns: 'int' }, + { + parameters: [receiver, { ...value, annotation: 'str' }], + returns: 'str', + }, + ], + }, + ]; + const ir = validateIrContract( + { + ...rawIr, + functions: [], + classes: [ + { + ...rawIr.classes[0]!, + name: 'Box', + qualname: 'fixture.Box', + methods, + fields: [], + is_dataclass: false, + }, + ], + }, + 'exact integer method contract' + ); + expect(ir.ok).toBe(true); + if (!ir.ok) { + return; + } + const original = moduleModel.functions[1]!; + const model: PythonModule = { + ...moduleModel, + functions: [], + classes: [ + { + ...moduleModel.classes[0]!, + name: 'Box', + kind: 'class', + methods: methods.map(method => ({ + ...original, + name: method.name, + methodKind: method.method_kind as 'class' | 'static', + })), + properties: [], + }, + ], + }; + const capabilities = DEFAULT_CALLABLE_CAPABILITIES.map(capability => + capability.name === 'exact-integer-adapter' ? { ...capability, available: true } : capability + ); + const compiled = compileContract(ir.contract, { + module: model, + generator: new CodeGenerator(), + conversion: EXACT_INTEGER_VALUE_CONVERSION, + capabilities, + }); + expect(compiled.generated.declaration).toContain( + 'static staticEcho(value: bigint): Promise;' + ); + expect(compiled.generated.declaration).toContain( + 'static classEcho(value: bigint): Promise;' + ); + expect(compiled.generated.declaration).toContain( + 'static classEcho(value: string): Promise;' + ); + expect(compiled.generated.typescript).toContain( + 'static async staticEcho(value: bigint): Promise' + ); + }); + + it('types exact variadic inputs and rejects an unrepresentable mixed keyword shape', async () => { + const source = rawIr.functions[1]!; + const functions = [ + { + ...source, + name: 'spread_exact', + qualname: 'fixture.spread_exact', + parameters: [{ name: 'values', kind: 'VAR_POSITIONAL', annotation: 'int', default: false }], + returns: 'int', + }, + { + ...source, + name: 'keyword_exact', + qualname: 'fixture.keyword_exact', + parameters: [{ name: 'values', kind: 'VAR_KEYWORD', annotation: 'int', default: false }], + returns: 'int', + }, + ]; + const ir = validateIrContract({ ...rawIr, functions }, 'exact integer variadic contract'); + expect(ir.ok).toBe(true); + if (!ir.ok) { + return; + } + const module = { + ...moduleModel, + functions: functions.map(func => ({ ...moduleModel.functions[1]!, name: func.name })), + classes: [], + }; + const capabilities = DEFAULT_CALLABLE_CAPABILITIES.map(capability => + capability.name === 'exact-integer-adapter' ? { ...capability, available: true } : capability + ); + const compiled = compileContract(ir.contract, { + module, + generator: new CodeGenerator(), + conversion: EXACT_INTEGER_VALUE_CONVERSION, + capabilities, + }); + expect(compiled.generated.declaration).toContain( + 'spreadExact(...values: bigint[]): Promise' + ); + expect(compiled.generated.declaration).toContain( + 'keywordExact(kwargs?: Record): Promise' + ); + expect(compiled.generated.declaration).not.toContain('unknown[]'); + + const temporary = await mkdtemp(join(process.cwd(), 'test', '.tywrap-exact-variadic-')); + try { + const declarationPath = join(temporary, 'fixture.generated.d.ts'); + const consumerPath = join(temporary, 'consumer.ts'); + await writeFile(declarationPath, compiled.generated.declaration, 'utf8'); + await writeFile( + consumerPath, + [ + "import { spreadExact, keywordExact } from './fixture.generated.js';", + 'const spread: Promise = spreadExact(1n, 2n);', + 'const keyword: Promise = keywordExact({ value: 1n });', + 'void spread;', + 'void keyword;', + '// @ts-expect-error exact spread values require bigint', + 'spreadExact(1);', + '// @ts-expect-error exact keyword values require bigint', + 'keywordExact({ value: 1 });', + ].join('\n'), + 'utf8' + ); + const program = ts.createProgram([consumerPath], { + noEmit: true, + strict: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + types: [], + }); + expect(ts.getPreEmitDiagnostics(program)).toEqual([]); + } finally { + await rm(temporary, { recursive: true, force: true }); + } + + const mixed = validateIrContract( + { + ...rawIr, + functions: [ + { + ...functions[1]!, + parameters: [ + { name: 'flag', kind: 'KEYWORD_ONLY', annotation: 'bool', default: false }, + { name: 'values', kind: 'VAR_KEYWORD', annotation: 'int', default: false }, + ], + }, + ], + }, + 'mixed exact kwargs contract' + ); + expect(mixed.ok).toBe(true); + if (mixed.ok) { + expect(() => + compileContract(mixed.contract, { + module: { ...module, functions: [module.functions[1]!] }, + generator: new CodeGenerator(), + conversion: EXACT_INTEGER_VALUE_CONVERSION, + capabilities, + }) + ).toThrow(/exact \*\*kwargs cannot share a signature/); + } + }); + it('requires returned dataclass fields even when their constructor has defaults', () => { const point = rawIr.classes[0]!; const ir = validateIrContract( diff --git a/test/runtime_return_validation.test.ts b/test/runtime_return_validation.test.ts index 7a4594f2..d0c86043 100644 --- a/test/runtime_return_validation.test.ts +++ b/test/runtime_return_validation.test.ts @@ -57,7 +57,9 @@ describe('generated return validators', () => { values: [1n, 18446744073709551617n], }); expect(() => validator({ values: [1, 2n] })).toThrow(BridgeValidationError); - expect(() => validator({ values: ['1'] })).toThrow(/expected bigint, received string/); + expect(() => validator({ values: ['1'] })).toThrow(BridgeValidationError); + const leaf = createReturnValidator({ kind: 'primitive', type: 'bigint' }, 'fixture.exactValue'); + expect(() => leaf('1')).toThrow(/expected bigint, received string/); }); it('requires per-call provenance for a scalar float16 ndarray', () => { diff --git a/test/value_contracts_v3.test.ts b/test/value_contracts_v3.test.ts index 046f1cee..9f32e43e 100644 --- a/test/value_contracts_v3.test.ts +++ b/test/value_contracts_v3.test.ts @@ -12,7 +12,7 @@ const specification = JSON.parse( readFileSync(new URL('../docs/maintainers/value-contracts.v3.json', import.meta.url), 'utf8') ) as { revision: number; - base: { file: string; revision: number; sha256: string }; + base: { file: string; revision: number; hashCanonicalization: string; sha256: string }; exactIntegerEnvelopeCodecVersion: number; callPolicy: string; requiredCapability: string; @@ -49,7 +49,11 @@ describe('exact integer value policy', () => { expect(fixtures.revision).toBe(VALUE_CONTRACT_V3_REVISION); expect(specification.base.revision).toBe(VALUE_CONTRACT_REVISION); expect(baseRevision.revision).toBe(VALUE_CONTRACT_REVISION); - expect(createHash('sha256').update(base).digest('hex')).toBe(specification.base.sha256); + expect(specification.base.hashCanonicalization).toBe('UTF-8 with LF line endings'); + const canonical = base.toString('utf8').replace(/\r\n/g, '\n'); + expect(createHash('sha256').update(canonical, 'utf8').digest('hex')).toBe( + specification.base.sha256 + ); }); it('keeps semantic revision, envelope version, and call policy distinct', () => {