diff --git a/docs/overdefinition-cost.md b/docs/overdefinition-cost.md new file mode 100644 index 00000000..03ca5ea6 --- /dev/null +++ b/docs/overdefinition-cost.md @@ -0,0 +1,119 @@ +# Overdefinition Cost Model + +When multiple wires target the same output field (overdefinition), the engine +must decide which to evaluate first. A cheaper wire that resolves non-null +lets us skip expensive ones entirely. + +## Current model (binary) + +``` +cost 0 — can resolve without scheduling a tool call +cost 1 — requires a new tool call +``` + +`classifyOverdefinitionWire()` returns 0 or 1. `orderOverdefinedWires()` +sorts ascending, ties broken by authoring order. + +## New model (granular) + +### Cost tiers + +| Cost | Source description | +| ---- | --------------------------------------------------------------------------------------------------------------------------------- | +| 0 | Already resolved (value in `state`), already scheduled (Promise in `state`), input, context, const, literal, control, element ref | +| 1 | Sync tools (default), defines/locals chaining through cheap sources | +| 2 | Async tools (default), unknown/unresolvable tools | +| n | Explicit `ToolMetadata.cost` override | + +### Key rules + +1. **Already resolved or scheduled → cost 0.** If `state[trunkKey(ref)]` + is defined, the work is already done or in flight — using it is free. + +2. **Already-scheduled promises → cost 0** (not 1). The cost is already + paid by the time we reach overdefinition ordering. Awaiting a promise + that's already in flight costs nothing extra. + +3. **Pessimistic wire cost = sum of source costs.** A fallback chain + (`a || b || c`) may evaluate all sources, so the total potential cost is + the sum. Used for define/local recursive resolution. + +4. **Optimistic wire cost = cost of the first source.** The minimum you + will pay to try the wire. Used for overdefinition ordering + (`classifyOverdefinitionWire` returns this). + +5. **Define/local cost = min of incoming wire costs (pessimistic).** Defines + are inlined — the engine picks the cheapest incoming wire. + +6. **Tool cost defaults:** `sync: true` → cost 1, otherwise → cost 2. + An explicit `cost` on `ToolMetadata` overrides both. + +### ToolMetadata addition + +```ts +export interface ToolMetadata { + // ... existing fields ... + + /** + * Overdefinition priority cost. Lower values are tried first when + * multiple wires target the same field. + * + * Default: 1 for sync tools, 2 for async tools. + */ + cost?: number; +} +``` + +## Files changed + +| File | Change | +| ----------------------------------- | ------------------------------------------------------- | +| `bridge-types/src/index.ts` | Add `cost?: number` to `ToolMetadata` | +| `bridge-core/src/ExecutionTree.ts` | Replace 3 boolean helpers with 3 numeric cost functions | +| `bridge-compiler/src/codegen.ts` | Replace boolean classification with numeric | +| `bridge/test/coalesce-cost.test.ts` | Add sync-vs-async and explicit-cost scenarios | + +No changes needed to `resolveWires.ts` (`orderOverdefinedWires` already +sorts by arbitrary numbers) or `tree-types.ts` (interface already returns +`number`). + +## Implementation: ExecutionTree + +Replace `classifyOverdefinitionWire` body: + +``` +classifyOverdefinitionWire(wire) → computeExprCost(wire.sources[0].expr) +``` + +Optimistic cost — only the first source determines ordering priority. + +### `computeWireCost(wire, visited?)` — pessimistic + +- For each `source` in `wire.sources`: sum `computeExprCost(source.expr)` +- If `wire.catch?.ref`: add `computeRefCost(wire.catch.ref)` +- Return **sum** of all costs + +Used for recursive define/local resolution ("what's the total potential +cost of using this define?"). + +### `computeExprCost(expr, visited?)` + +- `literal` / `control` → 0 +- `ref` → `computeRefCost(expr.ref)` +- `ternary` → max(cond, then, else) +- `and` / `or` → max(left, right) + +### `computeRefCost(ref, visited?)` + +- `ref.element` → 0 +- `hasCachedRef(ref)` → 0 _(includes already-scheduled promises)_ +- `SELF_MODULE` input/context/const → 0 +- `__define_*` / `__local` → min of incoming wire costs (recursive, cycle → ∞) +- External tool → `lookupToolFn(this, toolName)` → read + `.bridge?.cost ?? (.bridge?.sync ? 1 : 2)`, default 2 for unknown + +## Implementation: Compiler + +Same tier logic but no runtime state and no `hasCachedRef`. Compiler +defaults unknown external tools to cost 2 (conservative). Defines and +locals use the same recursive-min approach. diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 65f599e8..49eb5ff2 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -2851,128 +2851,89 @@ class CodegenContext { wire: Wire, visited = new Set(), ): number { - return this.canResolveWireCheaply(wire, visited) ? 0 : 1; + // Optimistic cost — cost of the first source only. + return this.computeExprCost(wire.sources[0]!.expr, visited); } - private canResolveWireCheaply( - wire: Wire, - visited = new Set(), - ): boolean { - if (isLit(wire)) return true; - - if (isPull(wire)) { - if (!this.refIsZeroCost(wRef(wire), visited)) return false; - for (const fallback of fallbacks(wire) ?? []) { - if ( - eRef(fallback.expr) && - !this.refIsZeroCost(eRef(fallback.expr), visited) - ) { - return false; - } - } - if (catchRef(wire) && !this.refIsZeroCost(catchRef(wire)!, visited)) { - return false; - } - return true; + /** + * Pessimistic wire cost — sum of all source expression costs plus catch. + * Represents worst-case cost when all fallback sources fire. + */ + private computeWireCost(wire: Wire, visited: Set): number { + let cost = 0; + for (const source of wire.sources) { + cost += this.computeExprCost(source.expr, visited); } - - if (isTern(wire)) { - if (!this.refIsZeroCost(eRef(wTern(wire).cond), visited)) return false; - if ( - (wTern(wire).then as RefExpr).ref && - !this.refIsZeroCost((wTern(wire).then as RefExpr).ref, visited) - ) - return false; - if ( - (wTern(wire).else as RefExpr).ref && - !this.refIsZeroCost((wTern(wire).else as RefExpr).ref, visited) - ) - return false; - for (const fallback of fallbacks(wire) ?? []) { - if ( - eRef(fallback.expr) && - !this.refIsZeroCost(eRef(fallback.expr), visited) - ) { - return false; - } - } - if (catchRef(wire) && !this.refIsZeroCost(catchRef(wire)!, visited)) { - return false; - } - return true; - } - - if (isAndW(wire)) { - if (!this.refIsZeroCost(eRef(wAndOr(wire).left), visited)) return false; - if ( - eRef(wAndOr(wire).right) && - !this.refIsZeroCost(eRef(wAndOr(wire).right), visited) - ) { - return false; - } - for (const fallback of fallbacks(wire) ?? []) { - if ( - eRef(fallback.expr) && - !this.refIsZeroCost(eRef(fallback.expr), visited) - ) { - return false; - } - } - if (catchRef(wire) && !this.refIsZeroCost(catchRef(wire)!, visited)) { - return false; - } - return true; + if (catchRef(wire)) { + cost += this.computeRefCost(catchRef(wire)!, visited); } + return cost; + } - if (isOrW(wire)) { - if (!this.refIsZeroCost(eRef(wAndOr(wire).left), visited)) return false; - if ( - eRef(wAndOr(wire).right) && - !this.refIsZeroCost(eRef(wAndOr(wire).right), visited) - ) { - return false; - } - for (const fallback of fallbacks(wire) ?? []) { - if ( - eRef(fallback.expr) && - !this.refIsZeroCost(eRef(fallback.expr), visited) - ) { - return false; - } - } - if (catchRef(wire) && !this.refIsZeroCost(catchRef(wire)!, visited)) { - return false; - } - return true; + private computeExprCost(expr: Expression, visited: Set): number { + switch (expr.type) { + case "literal": + case "control": + return 0; + case "ref": + return this.computeRefCost(expr.ref, visited); + case "ternary": + return Math.max( + this.computeExprCost(expr.cond, visited), + this.computeExprCost(expr.then, visited), + this.computeExprCost(expr.else, visited), + ); + case "and": + case "or": + return Math.max( + this.computeExprCost(expr.left, visited), + this.computeExprCost(expr.right, visited), + ); } - - return false; } - private refIsZeroCost(ref: NodeRef, visited = new Set()): boolean { - if (ref.element) return true; + private computeRefCost(ref: NodeRef, visited: Set): number { + if (ref.element) return 0; + // Self-module input/context/const — free if ( ref.module === SELF_MODULE && ((ref.type === this.bridge.type && ref.field === this.bridge.field) || (ref.type === "Context" && ref.field === "context") || (ref.type === "Const" && ref.field === "const")) ) { - return true; + return 0; } - if (ref.module.startsWith("__define_")) return false; const key = refTrunkKey(ref); - if (visited.has(key)) return false; + if (visited.has(key)) return Infinity; visited.add(key); + // Define — recursive, cheapest incoming wire wins + if (ref.module.startsWith("__define_")) { + const incoming = this.bridge.wires.filter( + (wire) => refTrunkKey(wire.to) === key, + ); + let best = Infinity; + for (const wire of incoming) { + best = Math.min(best, this.computeWireCost(wire, visited)); + } + return best === Infinity ? 2 : best; + } + + // Local alias — recursive, cheapest incoming wire wins if (ref.module === "__local") { const incoming = this.bridge.wires.filter( (wire) => refTrunkKey(wire.to) === key, ); - return incoming.some((wire) => this.canResolveWireCheaply(wire, visited)); + let best = Infinity; + for (const wire of incoming) { + best = Math.min(best, this.computeWireCost(wire, visited)); + } + return best === Infinity ? 2 : best; } - return false; + // External tool — compiler has no metadata, default to async cost + return 2; } /** diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index bb01321b..3da63e4f 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -4,6 +4,7 @@ import { schedule as _schedule, trunkDependsOnElement, } from "./scheduleTools.ts"; +import { lookupToolFn } from "./toolLookup.ts"; import { internal } from "./tools/index.ts"; import type { EffectiveToolLog, ToolTrace } from "./tracing.ts"; import { @@ -1095,78 +1096,100 @@ export class ExecutionTree implements TreeContext { } classifyOverdefinitionWire(wire: Wire): number { - return this.canResolveWireWithoutScheduling(wire) ? 0 : 1; + // Optimistic cost — cost of the first source only. + // This is the minimum we'll pay; used for overdefinition ordering. + const visited = new Set(); + return this.computeExprCost(wire.sources[0]!.expr, visited); } - private canResolveWireWithoutScheduling( - wire: Wire, - visited = new Set(), - ): boolean { - // Check all source expressions + /** + * Pessimistic wire cost — sum of all source expression costs plus catch. + * Represents worst-case cost when all fallback sources fire. + */ + private computeWireCost(wire: Wire, visited: Set): number { + let cost = 0; for (const source of wire.sources) { - if (!this.canResolveExprWithoutScheduling(source.expr, visited)) { - return false; - } + cost += this.computeExprCost(source.expr, visited); } - // Check catch handler ref if (wire.catch && "ref" in wire.catch) { - if (!this.canResolveRefWithoutScheduling(wire.catch.ref, visited)) { - return false; - } + cost += this.computeRefCost(wire.catch.ref, visited); } - return true; + return cost; } - private canResolveExprWithoutScheduling( - expr: Expression, - visited: Set, - ): boolean { + private computeExprCost(expr: Expression, visited: Set): number { switch (expr.type) { case "literal": case "control": - return true; + return 0; case "ref": - return this.canResolveRefWithoutScheduling(expr.ref, visited); + return this.computeRefCost(expr.ref, visited); case "ternary": - return ( - this.canResolveExprWithoutScheduling(expr.cond, visited) && - this.canResolveExprWithoutScheduling(expr.then, visited) && - this.canResolveExprWithoutScheduling(expr.else, visited) + return Math.max( + this.computeExprCost(expr.cond, visited), + this.computeExprCost(expr.then, visited), + this.computeExprCost(expr.else, visited), ); case "and": case "or": - return ( - this.canResolveExprWithoutScheduling(expr.left, visited) && - this.canResolveExprWithoutScheduling(expr.right, visited) + return Math.max( + this.computeExprCost(expr.left, visited), + this.computeExprCost(expr.right, visited), ); } } - private canResolveRefWithoutScheduling( - ref: NodeRef, - visited = new Set(), - ): boolean { - if (ref.element) return true; - if (this.hasCachedRef(ref)) return true; + private computeRefCost(ref: NodeRef, visited: Set): number { + if (ref.element) return 0; + // Already resolved or already-scheduled promise — cost already paid + if (this.hasCachedRef(ref)) return 0; const key = ((ref as any)[TRUNK_KEY_CACHE] ??= trunkKey(ref)); - if (visited.has(key)) return false; + if (visited.has(key)) return Infinity; visited.add(key); - if (ref.module.startsWith("__define_")) return false; + // Self-module input/context/const — free + if ( + ref.module === SELF_MODULE && + ((ref.type === this.bridge?.type && ref.field === this.bridge?.field) || + ref.type === "Context" || + ref.type === "Const") + ) { + return 0; + } + // Define — recursive, best (cheapest) incoming wire wins + if (ref.module.startsWith("__define_")) { + const incoming = + this.bridge?.wires.filter((wire) => sameTrunk(wire.to, ref)) ?? []; + let best = Infinity; + for (const wire of incoming) { + best = Math.min(best, this.computeWireCost(wire, visited)); + } + return best === Infinity ? 2 : best; + } + + // Local alias — recursive, cheapest incoming wire wins if (ref.module === "__local") { const incoming = this.bridge?.wires.filter((wire) => sameTrunk(wire.to, ref)) ?? []; + let best = Infinity; for (const wire of incoming) { - if (this.canResolveWireWithoutScheduling(wire, visited)) { - return true; - } + best = Math.min(best, this.computeWireCost(wire, visited)); } - return false; + return best === Infinity ? 2 : best; } - return false; + // External tool — look up metadata for cost + const toolName = + ref.module === SELF_MODULE ? ref.field : `${ref.module}.${ref.field}`; + const fn = lookupToolFn(this, toolName); + if (fn) { + const meta = (fn as any).bridge; + if (meta?.cost != null) return meta.cost; + return meta?.sync ? 1 : 2; + } + return 2; } private hasCachedRef(ref: NodeRef): boolean { diff --git a/packages/bridge-types/src/index.ts b/packages/bridge-types/src/index.ts index d53087fd..7839ad82 100644 --- a/packages/bridge-types/src/index.ts +++ b/packages/bridge-types/src/index.ts @@ -98,6 +98,13 @@ export interface ToolMetadata { */ batch?: true | BatchToolMetadata; + /** + * Overdefinition priority cost. Lower values are tried first when + * multiple wires target the same field. + * Default: 1 for sync tools, 2 for async tools. + */ + cost?: number; + // ─── Observability ──────────────────────────────────────────────────── /** diff --git a/packages/bridge/test/coalesce-cost.test.ts b/packages/bridge/test/coalesce-cost.test.ts index ae7cc955..624f3a3c 100644 --- a/packages/bridge/test/coalesce-cost.test.ts +++ b/packages/bridge/test/coalesce-cost.test.ts @@ -266,6 +266,81 @@ regressionTest("overdefinition: cost-based prioritization", { }, }); +// ── Cost tiers: sync vs async and explicit cost ───────────────────────── + +regressionTest("overdefinition: sync beats async", { + bridge: bridge` + version 1.5 + + bridge SyncAsync.lookup { + with test.async.multitool as slow + with test.sync.multitool as fast + with input as i + with output as o + + slow <- i.data + fast <- i.data + + o.label <- slow.label + o.label <- fast.label + } + `, + tools: tools, + scenarios: { + "SyncAsync.lookup": { + "sync tool (cost 1) tried before async (cost 2)": { + input: { data: { label: "hello" } }, + allowDowngrade: true, + assertData: { label: "hello" }, + // sync tool fires first (cost 1) and succeeds → async never called + assertTraces: 1, + }, + "sync null → async fires": { + input: { data: {} }, + allowDowngrade: true, + assertData: { label: undefined }, + assertTraces: 2, + }, + }, + }, +}); + +regressionTest("overdefinition: explicit cost override", { + bridge: bridge` + version 1.5 + + bridge ExplCost.lookup { + with test.async.multitool as expensive + with test.cheap.multitool as cheap + with input as i + with output as o + + expensive <- i.data + cheap <- i.data + + o.label <- expensive.label + o.label <- cheap.label + } + `, + tools: tools, + scenarios: { + "ExplCost.lookup": { + "cost-0 tool tried before async tool": { + input: { data: { label: "win" } }, + allowDowngrade: true, + assertData: { label: "win" }, + assertTraces: 1, + }, + "cost-0 null → async fires": { + input: { data: {} }, + allowDowngrade: true, + assertData: { label: undefined }, + assertTraces: 2, + }, + }, + }, +}); + // ── ?. safe execution modifier ──────────────────────────────────────────── regressionTest("?. safe execution modifier", { diff --git a/packages/bridge/test/utils/bridge-tools.ts b/packages/bridge/test/utils/bridge-tools.ts index 50b58f36..538a0cf1 100644 --- a/packages/bridge/test/utils/bridge-tools.ts +++ b/packages/bridge/test/utils/bridge-tools.ts @@ -59,6 +59,17 @@ batchMultitool.bridge = { log: { execution: "info" }, }; +function cheapMultitool(input: Record, _context: ToolContext) { + if (input?._error) { + throw new Error(String(input._error)); + } + return cleanupInstructions(input); +} +cheapMultitool.bridge = { + sync: true, + cost: 0, +}; + export const tools = { test: { multitool: (a: any, c: ToolContext) => { @@ -76,6 +87,9 @@ export const tools = { batch: { multitool: batchMultitool, }, + cheap: { + multitool: cheapMultitool, + }, }, };