test(tools): make the deep oneOf-object test a real quadratic tripwire
The 60k oneOf-object test had only one object node (the innermost), so the pre-fix code called allocateClassName once — linear, never tripping the timeout, so it did not cover the class-name Θ(depth²) it named. Give every level an object branch (both oneOf arms are objects) so each level propagates a one-segment-longer class name; the pre-fix rope slice is then Θ(depth²) (~9.5s, past the 5s default) while the capped path stays linear. Also extract the shared cap expression into capClassNameBase (used by allocateClassName and childClassName). py-types.ts stays at 100% per-file coverage.
This commit is contained in:
@@ -125,6 +125,11 @@ function camelCase(raw: string): string {
|
|||||||
/** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */
|
/** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */
|
||||||
const MAX_CLASS_NAME_BASE = 120
|
const MAX_CLASS_NAME_BASE = 120
|
||||||
|
|
||||||
|
/** Cap a class-name base at {@link MAX_CLASS_NAME_BASE} (see the callers for why capping keeps the render linear). */
|
||||||
|
function capClassNameBase(base: string): string {
|
||||||
|
return base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserve a unique class name from a base, suffixing `2`, `3`, … on collision.
|
* Reserve a unique class name from a base, suffixing `2`, `3`, … on collision.
|
||||||
* The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names
|
* The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names
|
||||||
@@ -137,7 +142,7 @@ const MAX_CLASS_NAME_BASE = 120
|
|||||||
* (amortized) instead of Θ(depth²) in time.
|
* (amortized) instead of Θ(depth²) in time.
|
||||||
*/
|
*/
|
||||||
function allocateClassName(base: string, state: RenderState): string {
|
function allocateClassName(base: string, state: RenderState): string {
|
||||||
const capped = base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base
|
const capped = capClassNameBase(base)
|
||||||
let name = capped
|
let name = capped
|
||||||
if (state.usedClassNames.has(name)) {
|
if (state.usedClassNames.has(name)) {
|
||||||
let n = state.nextClassCounter.get(capped) ?? 2
|
let n = state.nextClassCounter.get(capped) ?? 2
|
||||||
@@ -158,8 +163,7 @@ function allocateClassName(base: string, state: RenderState): string {
|
|||||||
* The bounded base plus the collision counter still yields unique names.
|
* The bounded base plus the collision counter still yields unique names.
|
||||||
*/
|
*/
|
||||||
function childClassName(base: string, segment: string): string {
|
function childClassName(base: string, segment: string): string {
|
||||||
const joined = `${base}${segment}`
|
return capClassNameBase(`${base}${segment}`)
|
||||||
return joined.length > MAX_CLASS_NAME_BASE ? joined.slice(0, MAX_CLASS_NAME_BASE) : joined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -485,15 +485,17 @@ describe('renderToolsSdkPy', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('names a deep oneOf-of-object chain in linear time (bounded propagated class names)', () => {
|
it('names a deep oneOf-of-object chain in linear time (bounded propagated class names)', () => {
|
||||||
// Every level is a oneOf whose first branch is a closed empty object (a
|
// Every level is a oneOf whose SECOND branch is a named object (a closed
|
||||||
// named TypedDict) and recurses. Propagating the full ancestor path as the
|
// empty TypedDict) and whose first branch recurses — so every level has an
|
||||||
// class name and slicing it in allocateClassName at every level would be
|
// object node, each propagating a class name one segment longer. Without a
|
||||||
// Theta(depth^2); childClassName caps the propagated base so it stays
|
// propagation cap, allocateClassName slices an ever-longer rope at every
|
||||||
// linear. The quadratic path at this depth exceeds the 5s default.
|
// level → Theta(depth^2) (~9.5s at this depth, past the 5s default);
|
||||||
|
// childClassName caps the base so it stays linear (~ms). Assertions are
|
||||||
|
// shape-based but the depth is the tripwire: a regression times out.
|
||||||
const depth = 60000
|
const depth = 60000
|
||||||
let deep: Record<string, unknown> = { type: 'object', additionalProperties: false, properties: {} }
|
let deep: Record<string, unknown> = { type: 'object', additionalProperties: false, properties: {} }
|
||||||
for (let i = 0; i < depth; i++) {
|
for (let i = 0; i < depth; i++) {
|
||||||
deep = { oneOf: [deep, { type: 'null' }] }
|
deep = { oneOf: [deep, { type: 'object', additionalProperties: false, properties: {} }] }
|
||||||
}
|
}
|
||||||
const tool: ToolSdkSchema = { name: 'deep', description: 'Deep oneOf-object chain.', parameters: { type: 'object', additionalProperties: false, properties: { root: deep }, required: ['root'] }, output: { type: 'string' } }
|
const tool: ToolSdkSchema = { name: 'deep', description: 'Deep oneOf-object chain.', parameters: { type: 'object', additionalProperties: false, properties: { root: deep }, required: ['root'] }, output: { type: 'string' } }
|
||||||
const text = renderToolsSdkPy([tool])
|
const text = renderToolsSdkPy([tool])
|
||||||
|
|||||||
Reference in New Issue
Block a user