Skip to content

Commit 06ecf9d

Browse files
authored
Calculate wire cost (#134)
1 parent 11c5a06 commit 06ecf9d

6 files changed

Lines changed: 336 additions & 137 deletions

File tree

docs/overdefinition-cost.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Overdefinition Cost Model
2+
3+
When multiple wires target the same output field (overdefinition), the engine
4+
must decide which to evaluate first. A cheaper wire that resolves non-null
5+
lets us skip expensive ones entirely.
6+
7+
## Current model (binary)
8+
9+
```
10+
cost 0 — can resolve without scheduling a tool call
11+
cost 1 — requires a new tool call
12+
```
13+
14+
`classifyOverdefinitionWire()` returns 0 or 1. `orderOverdefinedWires()`
15+
sorts ascending, ties broken by authoring order.
16+
17+
## New model (granular)
18+
19+
### Cost tiers
20+
21+
| Cost | Source description |
22+
| ---- | --------------------------------------------------------------------------------------------------------------------------------- |
23+
| 0 | Already resolved (value in `state`), already scheduled (Promise in `state`), input, context, const, literal, control, element ref |
24+
| 1 | Sync tools (default), defines/locals chaining through cheap sources |
25+
| 2 | Async tools (default), unknown/unresolvable tools |
26+
| n | Explicit `ToolMetadata.cost` override |
27+
28+
### Key rules
29+
30+
1. **Already resolved or scheduled → cost 0.** If `state[trunkKey(ref)]`
31+
is defined, the work is already done or in flight — using it is free.
32+
33+
2. **Already-scheduled promises → cost 0** (not 1). The cost is already
34+
paid by the time we reach overdefinition ordering. Awaiting a promise
35+
that's already in flight costs nothing extra.
36+
37+
3. **Pessimistic wire cost = sum of source costs.** A fallback chain
38+
(`a || b || c`) may evaluate all sources, so the total potential cost is
39+
the sum. Used for define/local recursive resolution.
40+
41+
4. **Optimistic wire cost = cost of the first source.** The minimum you
42+
will pay to try the wire. Used for overdefinition ordering
43+
(`classifyOverdefinitionWire` returns this).
44+
45+
5. **Define/local cost = min of incoming wire costs (pessimistic).** Defines
46+
are inlined — the engine picks the cheapest incoming wire.
47+
48+
6. **Tool cost defaults:** `sync: true` → cost 1, otherwise → cost 2.
49+
An explicit `cost` on `ToolMetadata` overrides both.
50+
51+
### ToolMetadata addition
52+
53+
```ts
54+
export interface ToolMetadata {
55+
// ... existing fields ...
56+
57+
/**
58+
* Overdefinition priority cost. Lower values are tried first when
59+
* multiple wires target the same field.
60+
*
61+
* Default: 1 for sync tools, 2 for async tools.
62+
*/
63+
cost?: number;
64+
}
65+
```
66+
67+
## Files changed
68+
69+
| File | Change |
70+
| ----------------------------------- | ------------------------------------------------------- |
71+
| `bridge-types/src/index.ts` | Add `cost?: number` to `ToolMetadata` |
72+
| `bridge-core/src/ExecutionTree.ts` | Replace 3 boolean helpers with 3 numeric cost functions |
73+
| `bridge-compiler/src/codegen.ts` | Replace boolean classification with numeric |
74+
| `bridge/test/coalesce-cost.test.ts` | Add sync-vs-async and explicit-cost scenarios |
75+
76+
No changes needed to `resolveWires.ts` (`orderOverdefinedWires` already
77+
sorts by arbitrary numbers) or `tree-types.ts` (interface already returns
78+
`number`).
79+
80+
## Implementation: ExecutionTree
81+
82+
Replace `classifyOverdefinitionWire` body:
83+
84+
```
85+
classifyOverdefinitionWire(wire) → computeExprCost(wire.sources[0].expr)
86+
```
87+
88+
Optimistic cost — only the first source determines ordering priority.
89+
90+
### `computeWireCost(wire, visited?)` — pessimistic
91+
92+
- For each `source` in `wire.sources`: sum `computeExprCost(source.expr)`
93+
- If `wire.catch?.ref`: add `computeRefCost(wire.catch.ref)`
94+
- Return **sum** of all costs
95+
96+
Used for recursive define/local resolution ("what's the total potential
97+
cost of using this define?").
98+
99+
### `computeExprCost(expr, visited?)`
100+
101+
- `literal` / `control` → 0
102+
- `ref``computeRefCost(expr.ref)`
103+
- `ternary` → max(cond, then, else)
104+
- `and` / `or` → max(left, right)
105+
106+
### `computeRefCost(ref, visited?)`
107+
108+
- `ref.element` → 0
109+
- `hasCachedRef(ref)` → 0 _(includes already-scheduled promises)_
110+
- `SELF_MODULE` input/context/const → 0
111+
- `__define_*` / `__local` → min of incoming wire costs (recursive, cycle → ∞)
112+
- External tool → `lookupToolFn(this, toolName)` → read
113+
`.bridge?.cost ?? (.bridge?.sync ? 1 : 2)`, default 2 for unknown
114+
115+
## Implementation: Compiler
116+
117+
Same tier logic but no runtime state and no `hasCachedRef`. Compiler
118+
defaults unknown external tools to cost 2 (conservative). Defines and
119+
locals use the same recursive-min approach.

packages/bridge-compiler/src/codegen.ts

Lines changed: 58 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -2851,128 +2851,89 @@ class CodegenContext {
28512851
wire: Wire,
28522852
visited = new Set<string>(),
28532853
): number {
2854-
return this.canResolveWireCheaply(wire, visited) ? 0 : 1;
2854+
// Optimistic cost — cost of the first source only.
2855+
return this.computeExprCost(wire.sources[0]!.expr, visited);
28552856
}
28562857

2857-
private canResolveWireCheaply(
2858-
wire: Wire,
2859-
visited = new Set<string>(),
2860-
): boolean {
2861-
if (isLit(wire)) return true;
2862-
2863-
if (isPull(wire)) {
2864-
if (!this.refIsZeroCost(wRef(wire), visited)) return false;
2865-
for (const fallback of fallbacks(wire) ?? []) {
2866-
if (
2867-
eRef(fallback.expr) &&
2868-
!this.refIsZeroCost(eRef(fallback.expr), visited)
2869-
) {
2870-
return false;
2871-
}
2872-
}
2873-
if (catchRef(wire) && !this.refIsZeroCost(catchRef(wire)!, visited)) {
2874-
return false;
2875-
}
2876-
return true;
2858+
/**
2859+
* Pessimistic wire cost — sum of all source expression costs plus catch.
2860+
* Represents worst-case cost when all fallback sources fire.
2861+
*/
2862+
private computeWireCost(wire: Wire, visited: Set<string>): number {
2863+
let cost = 0;
2864+
for (const source of wire.sources) {
2865+
cost += this.computeExprCost(source.expr, visited);
28772866
}
2878-
2879-
if (isTern(wire)) {
2880-
if (!this.refIsZeroCost(eRef(wTern(wire).cond), visited)) return false;
2881-
if (
2882-
(wTern(wire).then as RefExpr).ref &&
2883-
!this.refIsZeroCost((wTern(wire).then as RefExpr).ref, visited)
2884-
)
2885-
return false;
2886-
if (
2887-
(wTern(wire).else as RefExpr).ref &&
2888-
!this.refIsZeroCost((wTern(wire).else as RefExpr).ref, visited)
2889-
)
2890-
return false;
2891-
for (const fallback of fallbacks(wire) ?? []) {
2892-
if (
2893-
eRef(fallback.expr) &&
2894-
!this.refIsZeroCost(eRef(fallback.expr), visited)
2895-
) {
2896-
return false;
2897-
}
2898-
}
2899-
if (catchRef(wire) && !this.refIsZeroCost(catchRef(wire)!, visited)) {
2900-
return false;
2901-
}
2902-
return true;
2903-
}
2904-
2905-
if (isAndW(wire)) {
2906-
if (!this.refIsZeroCost(eRef(wAndOr(wire).left), visited)) return false;
2907-
if (
2908-
eRef(wAndOr(wire).right) &&
2909-
!this.refIsZeroCost(eRef(wAndOr(wire).right), visited)
2910-
) {
2911-
return false;
2912-
}
2913-
for (const fallback of fallbacks(wire) ?? []) {
2914-
if (
2915-
eRef(fallback.expr) &&
2916-
!this.refIsZeroCost(eRef(fallback.expr), visited)
2917-
) {
2918-
return false;
2919-
}
2920-
}
2921-
if (catchRef(wire) && !this.refIsZeroCost(catchRef(wire)!, visited)) {
2922-
return false;
2923-
}
2924-
return true;
2867+
if (catchRef(wire)) {
2868+
cost += this.computeRefCost(catchRef(wire)!, visited);
29252869
}
2870+
return cost;
2871+
}
29262872

2927-
if (isOrW(wire)) {
2928-
if (!this.refIsZeroCost(eRef(wAndOr(wire).left), visited)) return false;
2929-
if (
2930-
eRef(wAndOr(wire).right) &&
2931-
!this.refIsZeroCost(eRef(wAndOr(wire).right), visited)
2932-
) {
2933-
return false;
2934-
}
2935-
for (const fallback of fallbacks(wire) ?? []) {
2936-
if (
2937-
eRef(fallback.expr) &&
2938-
!this.refIsZeroCost(eRef(fallback.expr), visited)
2939-
) {
2940-
return false;
2941-
}
2942-
}
2943-
if (catchRef(wire) && !this.refIsZeroCost(catchRef(wire)!, visited)) {
2944-
return false;
2945-
}
2946-
return true;
2873+
private computeExprCost(expr: Expression, visited: Set<string>): number {
2874+
switch (expr.type) {
2875+
case "literal":
2876+
case "control":
2877+
return 0;
2878+
case "ref":
2879+
return this.computeRefCost(expr.ref, visited);
2880+
case "ternary":
2881+
return Math.max(
2882+
this.computeExprCost(expr.cond, visited),
2883+
this.computeExprCost(expr.then, visited),
2884+
this.computeExprCost(expr.else, visited),
2885+
);
2886+
case "and":
2887+
case "or":
2888+
return Math.max(
2889+
this.computeExprCost(expr.left, visited),
2890+
this.computeExprCost(expr.right, visited),
2891+
);
29472892
}
2948-
2949-
return false;
29502893
}
29512894

2952-
private refIsZeroCost(ref: NodeRef, visited = new Set<string>()): boolean {
2953-
if (ref.element) return true;
2895+
private computeRefCost(ref: NodeRef, visited: Set<string>): number {
2896+
if (ref.element) return 0;
2897+
// Self-module input/context/const — free
29542898
if (
29552899
ref.module === SELF_MODULE &&
29562900
((ref.type === this.bridge.type && ref.field === this.bridge.field) ||
29572901
(ref.type === "Context" && ref.field === "context") ||
29582902
(ref.type === "Const" && ref.field === "const"))
29592903
) {
2960-
return true;
2904+
return 0;
29612905
}
2962-
if (ref.module.startsWith("__define_")) return false;
29632906

29642907
const key = refTrunkKey(ref);
2965-
if (visited.has(key)) return false;
2908+
if (visited.has(key)) return Infinity;
29662909
visited.add(key);
29672910

2911+
// Define — recursive, cheapest incoming wire wins
2912+
if (ref.module.startsWith("__define_")) {
2913+
const incoming = this.bridge.wires.filter(
2914+
(wire) => refTrunkKey(wire.to) === key,
2915+
);
2916+
let best = Infinity;
2917+
for (const wire of incoming) {
2918+
best = Math.min(best, this.computeWireCost(wire, visited));
2919+
}
2920+
return best === Infinity ? 2 : best;
2921+
}
2922+
2923+
// Local alias — recursive, cheapest incoming wire wins
29682924
if (ref.module === "__local") {
29692925
const incoming = this.bridge.wires.filter(
29702926
(wire) => refTrunkKey(wire.to) === key,
29712927
);
2972-
return incoming.some((wire) => this.canResolveWireCheaply(wire, visited));
2928+
let best = Infinity;
2929+
for (const wire of incoming) {
2930+
best = Math.min(best, this.computeWireCost(wire, visited));
2931+
}
2932+
return best === Infinity ? 2 : best;
29732933
}
29742934

2975-
return false;
2935+
// External tool — compiler has no metadata, default to async cost
2936+
return 2;
29762937
}
29772938

29782939
/**

0 commit comments

Comments
 (0)