Lift a record field whose function value narrows its parameters

A record width conversion copies a function-valued field when the field's
signature adapts (cleanFuncAdaptable). That gate asked coercibleValue of each
parameter pair, which excludes the checked-dynamic extraction and the
structural width the parameter conversion actually performs, so a definition
whose `execute`/`renderCall` take a concrete parameter record could not
complete a slot declaring `unknown` members — the SC2002 `field 'execute'` /
`field 'renderCall'` shape that dominates the census's tool-definition sites.

The gate now measures what funcCoerceAdapter converts: cleanParamPair for
parameters (so a callback parameter recurses through the same rule one
function type down) and cleanResult for the result (a structural width, which
carries the promise payload widening, and the void-into-unknown drop). A
recursion guard keyed in coercions.planning keeps structurally recursive
function aliases from looping, the coinductive answer recordWidthPlan already
gives its own back-edges.

A parameter that narrows a dynamic member to a record is the checked-dynamic
extraction, the same conversion a cast performs: an argument of the wrong
shape throws from the adapter rather than reading undefined, as with any
checked conversion in the language.

The gate widens admission only: a pair the old gate refused was an SC2002
error, so programs that already compiled keep byte-identical IR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:14 -07:00
co-authored by Claude Opus 5.5
parent b200bde558
commit 3c5b9eadd2
4 changed files with 151 additions and 9 deletions
@@ -129,7 +129,13 @@ export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean
* parameters are ignored. A source with MORE parameters than the slot is
* clean when every extra trailing one is omittable — the adapter passes
* that absent value explicitly (funcCoerceAdapter's omittedParams), the
* same way a call through the slot would. */
* same way a call through the slot would. A parameter the slot coercion
* converts through a STRUCTURAL width counts (cleanParamPair), which
* recurses through a nested function type: a callback parameter of the
* value taking a narrower record where the slot's takes its `unknown` view
* converts the argument the same way a top-level call does. The result side
* likewise accepts what funcCoerceAdapter converts (cleanResult), including
* a promise whose payload widens. */
export function cleanFuncAdaptable(
lowerer: Lowerer,
src: IrType & { kind: "func" },
@@ -137,15 +143,35 @@ export function cleanFuncAdaptable(
): boolean {
if (restPackingAdapterFits(lowerer, src, dst)) return true;
if (src.rest === true || dst.rest === true) return false;
for (let i = dst.params.length; i < src.params.length; i++) {
if (!omittableTrailingParam(lowerer, src.params[i]!)) return false;
// Recursive function types (`type Handler = (h: Handler) => void`) reach
// the same pair again one parameter down: assume it converts, the same
// coinductive answer recordWidthPlan gives its own back-edges.
const key = `funcClean:${typeKey(src)}:${typeKey(dst)}`;
if (lowerer.coercions.planning.has(key)) return true;
lowerer.coercions.planning.add(key);
try {
for (let i = dst.params.length; i < src.params.length; i++) {
if (!omittableTrailingParam(lowerer, src.params[i]!)) return false;
}
for (let i = 0; i < src.params.length && i < dst.params.length; i++) {
if (!cleanParamPair(lowerer, dst.params[i]!, src.params[i]!)) return false;
}
return cleanResult(lowerer, src.ret, dst.ret);
} finally {
lowerer.coercions.planning.delete(key);
}
for (let i = 0; i < src.params.length && i < dst.params.length; i++) {
if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false;
}
if (dst.ret.kind === "void") return true;
if (lowerer.coercibleValue(src.ret, dst.ret)) return true;
return src.ret.kind === "void" && dst.ret.kind === "dyn";
}
/** Whether the slot's coercion converts the value's RESULT without a
* trap-only path: the dispositions funcCoerceAdapter's result side accepts
* (a void slot drops the value; coercibleValue; a structural width, which
* includes the promise payload widening and a nested function adaptation;
* the void-into-unknown drop). */
function cleanResult(lowerer: Lowerer, actual: IrType, expected: IrType): boolean {
if (expected.kind === "void") return true;
if (lowerer.coercibleValue(actual, expected)) return true;
if (actual.kind === "void" && expected.kind === "dyn") return true;
return lowerer.widthLiftPlan(actual, expected) !== null;
}
/** Whether the slot's argument type `actual` converts into the function's
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/record-func-field-width.ts"
],
"diags": []
}
+109
View File
@@ -0,0 +1,109 @@
// A record field holding a function adapts when the slot's function type is a
// structural width of the field's: a parameter record whose `unknown` members
// gain shapes (the checked-dynamic extraction), a callback parameter whose
// record widens the same way one level down, and a promise payload that widens.
//
// `wrapToolDefinition` states the definition's state and details parameters as
// `any`/default `unknown`, so passing a concrete definition crosses the
// `execute` and `renderCall` fields of the record.
interface Truncation {
content: string;
truncated: boolean;
}
interface ToolResult<D> {
content: { type: string; text: string }[];
details: D;
isError: boolean | undefined;
}
type UpdateCallback<D> = (partial: ToolResult<D>) => void;
interface ToolDefinition<S, D> {
name: string;
execute(id: string, onUpdate: UpdateCallback<D> | undefined): Promise<ToolResult<D>>;
renderCall(context: { state: S; label: string }): string;
}
interface BashState {
startedAt: number;
executionStarted: boolean;
}
interface BashDetails {
truncation: Truncation | undefined;
fullOutputPath: string | undefined;
}
function wrapToolDefinition<D = unknown>(
definition: ToolDefinition<any, D>,
): ToolDefinition<any, D> {
return {
name: definition.name,
execute: definition.execute,
renderCall: definition.renderCall,
};
}
function bashDefinition(name: string): ToolDefinition<BashState, BashDetails> {
return {
name,
execute: (id, onUpdate) => {
const result: ToolResult<BashDetails> = {
content: [{ type: "text", text: `ran ${id}` }],
details: { truncation: { content: "tail", truncated: true }, fullOutputPath: undefined },
isError: undefined,
};
onUpdate?.(result);
return Promise.resolve(result);
},
renderCall: (context) => `${context.label} after ${context.state.startedAt}ms`,
};
}
// The `execute` field: the slot's update callback takes `details: unknown`
// where the value's takes the concrete record, and the result promise payload
// widens the same way.
function widenDetails(): ToolDefinition<any, unknown> {
return wrapToolDefinition<unknown>(bashDefinition("bash"));
}
// The `renderCall` field: the slot's context record has `state: unknown`
// where the value's has the concrete state record.
function widenState(definition: ToolDefinition<BashState, BashDetails>): ToolDefinition<any, BashDetails> {
return wrapToolDefinition<BashDetails>(definition);
}
// The same crossing when the argument is an object literal carrying optional
// members the slot's shape also declares.
interface WithHints {
annotations: { readOnlyHint: boolean | undefined } | undefined;
defaultActive: boolean | undefined;
}
function widenLiteral(): ToolDefinition<any, BashDetails> {
const hints: WithHints = { annotations: { readOnlyHint: true }, defaultActive: undefined };
const definition: ToolDefinition<BashState, BashDetails> & WithHints = {
...bashDefinition("literal"),
...hints,
};
console.log("hints:", definition.annotations?.readOnlyHint, definition.defaultActive);
return wrapToolDefinition<BashDetails>(definition);
}
const widened = widenDetails();
widened
.execute("ls", (partial) => {
console.log("update:", partial.content[0]?.text, partial.isError);
})
.then((result) => {
console.log("result:", result.details, result.isError);
});
const rendered = widenState(bashDefinition("grep"));
console.log("render:", rendered.renderCall({ state: { startedAt: 12, executionStarted: true }, label: "grep" }));
const literal = widenLiteral();
console.log("literal render:", literal.renderCall({ state: { startedAt: 3, executionStarted: false }, label: "ls" }));
console.log("name:", literal.name);
@@ -0,0 +1 @@
{ "plain": 3050, "san": 4250 }