Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions docs/overdefinition-cost.md
Original file line number Diff line number Diff line change
@@ -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.
155 changes: 58 additions & 97 deletions packages/bridge-compiler/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2851,128 +2851,89 @@ class CodegenContext {
wire: Wire,
visited = new Set<string>(),
): 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<string>(),
): 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<string>): 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<string>): 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<string>()): boolean {
if (ref.element) return true;
private computeRefCost(ref: NodeRef, visited: Set<string>): 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;
}

/**
Expand Down
Loading
Loading