@loomwork/reasoning 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +82 -2
- package/dist/ReasoningRuntime.d.ts +22 -0
- package/dist/ReasoningRuntime.d.ts.map +1 -0
- package/dist/ReasoningRuntime.js +200 -0
- package/dist/ReasoningRuntime.js.map +1 -0
- package/dist/confidence.d.ts +5 -0
- package/dist/confidence.d.ts.map +1 -0
- package/dist/confidence.js +38 -0
- package/dist/confidence.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +39 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +173 -0
- package/dist/router.js.map +1 -0
- package/dist/routing.bench.d.ts +2 -0
- package/dist/routing.bench.d.ts.map +1 -0
- package/dist/routing.bench.js +48 -0
- package/dist/routing.bench.js.map +1 -0
- package/dist/types.d.ts +134 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -3
- package/src/ReasoningRuntime.ts +244 -0
- package/src/confidence.ts +37 -0
- package/src/index.ts +24 -0
- package/src/router.ts +225 -0
- package/src/routing.bench.ts +54 -0
- package/src/types.ts +178 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { LlmResponse } from "@loomwork/llm";
|
|
2
|
+
import {
|
|
3
|
+
assertRequestSupported,
|
|
4
|
+
guardRequest,
|
|
5
|
+
resolveRequestContent,
|
|
6
|
+
textFreeReasoning,
|
|
7
|
+
} from "@loomwork/llm";
|
|
8
|
+
import { stopReasonConfidence } from "./confidence";
|
|
9
|
+
import { tagRouter } from "./router";
|
|
10
|
+
import type {
|
|
11
|
+
ConfidenceSignal,
|
|
12
|
+
DecideFn,
|
|
13
|
+
ModelRoute,
|
|
14
|
+
ReasonOptions,
|
|
15
|
+
ReasoningCache,
|
|
16
|
+
ReasoningRequest,
|
|
17
|
+
ReasoningResult,
|
|
18
|
+
ReasoningRuntimeConfig,
|
|
19
|
+
ReasoningTraceEntry,
|
|
20
|
+
Router,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
// No decision sink → run the decider inline (used by standalone callers outside a
|
|
24
|
+
// durable flow). When a flow passes ctx.decide, the choice is recorded instead.
|
|
25
|
+
const identityDecide: DecideFn = (_id, decider) => Promise.resolve(decider());
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* ADR 0133 - what leaves this runtime (a result a caller journals, a cache entry) carries reasoning
|
|
29
|
+
* only in its text-free recorded form: readable text goes to a sidecar through `createLlmEffect`, or
|
|
30
|
+
* nowhere. The same object when there is no reasoning (G2).
|
|
31
|
+
*/
|
|
32
|
+
function withTextFreeReasoning(res: LlmResponse): LlmResponse {
|
|
33
|
+
return res.reasoning === undefined
|
|
34
|
+
? res
|
|
35
|
+
: { ...res, reasoning: textFreeReasoning(res.reasoning) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The Reasoning Runtime (P3), built as composable stages: route → call → score →
|
|
39
|
+
// elevate. Each route/elevation is a logged decision (P9), so replay is deterministic
|
|
40
|
+
// while routing can improve run-to-run. The kernel stays untouched — this is an
|
|
41
|
+
// optional advisory module (P10).
|
|
42
|
+
export class ReasoningRuntime {
|
|
43
|
+
private readonly routes: ModelRoute[];
|
|
44
|
+
private readonly router: Router;
|
|
45
|
+
private readonly confidence: ConfidenceSignal;
|
|
46
|
+
private readonly threshold: number;
|
|
47
|
+
private readonly cache: ReasoningCache | undefined;
|
|
48
|
+
// id → position in the tier-sorted `routes`, so `byId` is O(1) not a linear scan.
|
|
49
|
+
private readonly indexById: Map<string, number>;
|
|
50
|
+
// For each route index, the index of the next route with a strictly higher tier
|
|
51
|
+
// (or -1). Precomputed once so elevation is an O(1) hop instead of an O(n) scan.
|
|
52
|
+
private readonly nextHigherIndex: number[];
|
|
53
|
+
|
|
54
|
+
constructor(config: ReasoningRuntimeConfig) {
|
|
55
|
+
if (config.routes.length === 0) {
|
|
56
|
+
throw new Error("ReasoningRuntime requires at least one route");
|
|
57
|
+
}
|
|
58
|
+
// Sorted by tier once so elevation is a simple forward hop.
|
|
59
|
+
this.routes = [...config.routes].sort((a, b) => a.tier - b.tier);
|
|
60
|
+
this.router = config.router ?? tagRouter;
|
|
61
|
+
this.confidence = config.confidence ?? stopReasonConfidence;
|
|
62
|
+
this.threshold = config.elevateThreshold ?? 0.5;
|
|
63
|
+
this.cache = config.cache;
|
|
64
|
+
|
|
65
|
+
const n = this.routes.length;
|
|
66
|
+
this.indexById = new Map(this.routes.map((r, i) => [r.id, i]));
|
|
67
|
+
// Right-to-left pass (O(n)): the next strictly-higher tier for i is either its
|
|
68
|
+
// neighbour i+1 (if that neighbour's tier is greater) or, when i+1 shares i's
|
|
69
|
+
// tier, whatever the next-higher of i+1 already resolved to.
|
|
70
|
+
this.nextHigherIndex = new Array(n).fill(-1);
|
|
71
|
+
for (let i = n - 2; i >= 0; i--) {
|
|
72
|
+
this.nextHigherIndex[i] =
|
|
73
|
+
(this.routes[i + 1] as ModelRoute).tier > (this.routes[i] as ModelRoute).tier
|
|
74
|
+
? i + 1
|
|
75
|
+
: (this.nextHigherIndex[i + 1] as number);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a response cache is configured (a host that shares it across tenants must scope it). */
|
|
80
|
+
get hasResponseCache(): boolean {
|
|
81
|
+
return this.cache !== undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get availableRoutes(): ReadonlyArray<ModelRoute> {
|
|
85
|
+
return this.routes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async reason(req: ReasoningRequest, opts: ReasonOptions = {}): Promise<ReasoningResult> {
|
|
89
|
+
const decide = opts.decide ?? identityDecide;
|
|
90
|
+
const key = opts.decisionKey ?? req.strategy ?? "default";
|
|
91
|
+
const signal = opts.signal ?? req.signal;
|
|
92
|
+
const maxTier = req.maxTier ?? (this.routes[this.routes.length - 1] as ModelRoute).tier;
|
|
93
|
+
|
|
94
|
+
// Stage 1 — route (a logged decision).
|
|
95
|
+
const initialId = await decide(
|
|
96
|
+
`reason:${key}:route`,
|
|
97
|
+
() => this.router.select(req, this.routes).id
|
|
98
|
+
);
|
|
99
|
+
let currentIdx = this.indexOf(initialId);
|
|
100
|
+
let current = this.routes[currentIdx] as ModelRoute;
|
|
101
|
+
const trace: ReasoningTraceEntry[] = [];
|
|
102
|
+
|
|
103
|
+
let res = await this.call(current, req, signal, opts);
|
|
104
|
+
let confidence = await this.confidence.score(req, res);
|
|
105
|
+
trace.push({ modelId: current.id, tier: current.tier, confidence, elevated: false });
|
|
106
|
+
|
|
107
|
+
// Stage 2 — elevate while low-confidence and a higher tier is within budget.
|
|
108
|
+
let step = 0;
|
|
109
|
+
while (confidence < this.threshold) {
|
|
110
|
+
const nextIdx = this.nextHigherIndex[currentIdx] as number;
|
|
111
|
+
if (nextIdx === -1) break;
|
|
112
|
+
const next = this.routes[nextIdx] as ModelRoute;
|
|
113
|
+
if (next.tier > maxTier) break;
|
|
114
|
+
step += 1;
|
|
115
|
+
const chosenId = await decide(`reason:${key}:elevate:${step}`, () => next.id);
|
|
116
|
+
currentIdx = this.indexOf(chosenId);
|
|
117
|
+
current = this.routes[currentIdx] as ModelRoute;
|
|
118
|
+
res = await this.call(current, req, signal, opts);
|
|
119
|
+
confidence = await this.confidence.score(req, res);
|
|
120
|
+
trace.push({ modelId: current.id, tier: current.tier, confidence, elevated: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
content: res.content,
|
|
125
|
+
...(res.toolCalls !== undefined && { toolCalls: res.toolCalls }),
|
|
126
|
+
stopReason: res.stopReason,
|
|
127
|
+
...(res.usage !== undefined && { usage: res.usage }),
|
|
128
|
+
...(res.reasoning !== undefined && { reasoning: res.reasoning }),
|
|
129
|
+
...(res.layout !== undefined && { layout: res.layout }),
|
|
130
|
+
modelId: current.id,
|
|
131
|
+
confidence,
|
|
132
|
+
elevated: trace.length > 1,
|
|
133
|
+
trace,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private indexOf(id: string): number {
|
|
138
|
+
const idx = this.indexById.get(id);
|
|
139
|
+
if (idx === undefined) {
|
|
140
|
+
throw new Error(`ReasoningRuntime: unknown route "${id}"`);
|
|
141
|
+
}
|
|
142
|
+
return idx;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async call(
|
|
146
|
+
route: ModelRoute,
|
|
147
|
+
req: ReasoningRequest,
|
|
148
|
+
signal: AbortSignal | undefined,
|
|
149
|
+
opts: ReasonOptions
|
|
150
|
+
): Promise<LlmResponse> {
|
|
151
|
+
// Every path (an invoker, a wrapper serving a record, the cache, the provider) is text-free on exit.
|
|
152
|
+
return withTextFreeReasoning(await this.callRaw(route, req, signal, opts));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async callRaw(
|
|
156
|
+
route: ModelRoute,
|
|
157
|
+
req: ReasoningRequest,
|
|
158
|
+
signal: AbortSignal | undefined,
|
|
159
|
+
opts: ReasonOptions
|
|
160
|
+
): Promise<LlmResponse> {
|
|
161
|
+
const invoke = opts.invoke;
|
|
162
|
+
if (invoke !== undefined) {
|
|
163
|
+
// R9 — the host makes the call (as an effect, inside the ring). No cache: see ModelInvoker.
|
|
164
|
+
// `guardRequest` still runs here, so a custom invoker that is NOT an effect cannot
|
|
165
|
+
// receive tainted content either; the effect it resolves to guards again, harmlessly
|
|
166
|
+
// (a guarded request is untainted, and guarding it is the identity).
|
|
167
|
+
const guarded = guardRequest({
|
|
168
|
+
messages: req.messages,
|
|
169
|
+
...(req.system !== undefined && { system: req.system }),
|
|
170
|
+
...(req.tools !== undefined && { tools: req.tools }),
|
|
171
|
+
...(req.maxTokens !== undefined && { maxTokens: req.maxTokens }),
|
|
172
|
+
...(req.temperature !== undefined && { temperature: req.temperature }),
|
|
173
|
+
});
|
|
174
|
+
return invoke(route, {
|
|
175
|
+
messages: guarded.messages,
|
|
176
|
+
...(guarded.system !== undefined && { system: guarded.system }),
|
|
177
|
+
...(guarded.tools !== undefined && { tools: guarded.tools }),
|
|
178
|
+
...(guarded.maxTokens !== undefined && { maxTokens: guarded.maxTokens }),
|
|
179
|
+
...(guarded.temperature !== undefined && { temperature: guarded.temperature }),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// R2 — the prompt boundary, on the reasoning path. This is the single site where
|
|
183
|
+
// this runtime hands a request to a provider, so it is the one place the check
|
|
184
|
+
// belongs. `ReasoningRequest` types its content as `string`, but a `string`
|
|
185
|
+
// annotation is a claim, not a check: a tainted MCP tool description reaches
|
|
186
|
+
// here through exactly such a field. `guardRequest` refuses taint in every
|
|
187
|
+
// instruction position and returns an untainted request unchanged.
|
|
188
|
+
const guarded = guardRequest({
|
|
189
|
+
messages: req.messages,
|
|
190
|
+
...(req.system !== undefined && { system: req.system }),
|
|
191
|
+
...(req.tools !== undefined && { tools: req.tools }),
|
|
192
|
+
...(req.maxTokens !== undefined && { maxTokens: req.maxTokens }),
|
|
193
|
+
...(req.temperature !== undefined && { temperature: req.temperature }),
|
|
194
|
+
...(signal !== undefined && { signal }),
|
|
195
|
+
});
|
|
196
|
+
// R5 — a third-party provider may not check its own modalities; this runtime does.
|
|
197
|
+
assertRequestSupported(guarded, route.provider.capabilities);
|
|
198
|
+
// The cache key is the request AS WRITTEN (addresses, not bytes); computed before the host's
|
|
199
|
+
// wrapper so a journaled call and an unjournaled one share the same entries.
|
|
200
|
+
const cacheKey =
|
|
201
|
+
this.cache !== undefined ? this.keyFor(route.id, req, this.scopeOf(opts)) : undefined;
|
|
202
|
+
const run = async (): Promise<LlmResponse> => {
|
|
203
|
+
if (cacheKey !== undefined) {
|
|
204
|
+
const hit = this.cache?.get(cacheKey);
|
|
205
|
+
if (hit !== undefined) return hit;
|
|
206
|
+
}
|
|
207
|
+
// R5 — refs become bytes only now: after the capability check (a refusal must precede any
|
|
208
|
+
// fetch) and after the cache lookup, whose key is the request as written.
|
|
209
|
+
// ADR 0133: reasoning is reduced BEFORE the cache sees it, so a shared cache never holds text.
|
|
210
|
+
const res = withTextFreeReasoning(
|
|
211
|
+
await route.provider.complete(
|
|
212
|
+
await resolveRequestContent(guarded, opts.resolveContent, {
|
|
213
|
+
...(signal !== undefined && { signal }),
|
|
214
|
+
// Absent only when there is no resolver; a ref then fails before any fetch.
|
|
215
|
+
context: opts.resolveContext ?? { executionId: "" },
|
|
216
|
+
})
|
|
217
|
+
)
|
|
218
|
+
);
|
|
219
|
+
if (cacheKey !== undefined) this.cache?.set(cacheKey, res);
|
|
220
|
+
return res;
|
|
221
|
+
};
|
|
222
|
+
return opts.wrapCall !== undefined ? opts.wrapCall(route, run) : run();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Who the cached response belongs to: the explicit scope and the principal identity, if any. */
|
|
226
|
+
private scopeOf(opts: ReasonOptions): string | undefined {
|
|
227
|
+
const p = opts.resolveContext?.principal;
|
|
228
|
+
const who =
|
|
229
|
+
p === undefined
|
|
230
|
+
? undefined
|
|
231
|
+
: JSON.stringify([p.tenant ?? null, p.actor ?? null, p.onBehalfOf ?? null]);
|
|
232
|
+
if (opts.cacheScope === undefined && who === undefined) return undefined;
|
|
233
|
+
return JSON.stringify([opts.cacheScope ?? null, who ?? null]);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private keyFor(modelId: string, req: ReasoningRequest, scope?: string): string {
|
|
237
|
+
return JSON.stringify({
|
|
238
|
+
m: modelId,
|
|
239
|
+
s: req.system,
|
|
240
|
+
msgs: req.messages,
|
|
241
|
+
...(scope !== undefined && { c: scope }),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LlmResponse } from "@loomwork/llm";
|
|
2
|
+
import type { ConfidenceSignal, ReasoningRequest } from "./types";
|
|
3
|
+
|
|
4
|
+
// Honest, pluggable confidence — NOT a magic self-reported number. The default uses
|
|
5
|
+
// the model's stop reason, the one signal every provider emits: a truncated answer
|
|
6
|
+
// (max_tokens) is low-confidence and should elevate; a clean finish is high.
|
|
7
|
+
export const stopReasonConfidence: ConfidenceSignal = {
|
|
8
|
+
score(_req: ReasoningRequest, res: LlmResponse): number {
|
|
9
|
+
switch (res.stopReason) {
|
|
10
|
+
case "end_turn":
|
|
11
|
+
return 0.9;
|
|
12
|
+
case "tool_use":
|
|
13
|
+
return 0.9;
|
|
14
|
+
case "max_tokens":
|
|
15
|
+
return 0.3;
|
|
16
|
+
default:
|
|
17
|
+
return 0.5;
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Confidence from a caller-supplied validator: pass → 1, fail → 0. Use for structured
|
|
23
|
+
// outputs ("did it parse as the expected JSON / a known capability id?"). This turns
|
|
24
|
+
// elevation into "retry on a stronger model until the output validates."
|
|
25
|
+
export function validatorConfidence(validate: (content: string) => boolean): ConfidenceSignal {
|
|
26
|
+
return {
|
|
27
|
+
score(_req: ReasoningRequest, res: LlmResponse): number {
|
|
28
|
+
return validate(res.content) ? 1 : 0;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Agreement across a fixed constant — always returns the given value. Useful to force
|
|
34
|
+
// (never elevate → 1) or always-elevate (→ 0) in tests and pins.
|
|
35
|
+
export function constantConfidence(value: number): ConfidenceSignal {
|
|
36
|
+
return { score: () => value };
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { ReasoningRuntime } from "./ReasoningRuntime";
|
|
2
|
+
export { tagRouter, fixedRouter, rankedRouter, ucb1, epsilonGreedy } from "./router";
|
|
3
|
+
export type {
|
|
4
|
+
ArmStats,
|
|
5
|
+
EpsilonGreedyOptions,
|
|
6
|
+
RankedRouterOptions,
|
|
7
|
+
Rankings,
|
|
8
|
+
Ucb1Options,
|
|
9
|
+
} from "./router";
|
|
10
|
+
export { stopReasonConfidence, validatorConfidence, constantConfidence } from "./confidence";
|
|
11
|
+
export type {
|
|
12
|
+
ConfidenceSignal,
|
|
13
|
+
DecideFn,
|
|
14
|
+
ModelCallRequest,
|
|
15
|
+
ModelInvoker,
|
|
16
|
+
ModelRoute,
|
|
17
|
+
ReasonOptions,
|
|
18
|
+
ReasoningCache,
|
|
19
|
+
ReasoningRequest,
|
|
20
|
+
ReasoningResult,
|
|
21
|
+
ReasoningRuntimeConfig,
|
|
22
|
+
ReasoningTraceEntry,
|
|
23
|
+
Router,
|
|
24
|
+
} from "./types";
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { ModelRoute, ReasoningRequest, Router } from "./types";
|
|
2
|
+
|
|
3
|
+
// Default routing policy: the lowest-tier route whose tags include the request's
|
|
4
|
+
// strategy, else the lowest-tier route overall. This implements "use the cheap
|
|
5
|
+
// model first; elevate only when confidence is low" (the P3 elevation ladder).
|
|
6
|
+
//
|
|
7
|
+
// Contract: `routes` are expected tier-ascending. ReasoningRuntime owns the sort
|
|
8
|
+
// (once, at construction) and always passes its sorted `routes`, so this router
|
|
9
|
+
// takes the first tag/overall match without re-sorting on every call.
|
|
10
|
+
export const tagRouter: Router = {
|
|
11
|
+
select(req: ReasoningRequest, routes: ModelRoute[]): ModelRoute {
|
|
12
|
+
if (req.strategy !== undefined) {
|
|
13
|
+
const tagged = routes.find((r) => r.tags?.includes(req.strategy as string));
|
|
14
|
+
if (tagged !== undefined) return tagged;
|
|
15
|
+
}
|
|
16
|
+
const first = routes[0];
|
|
17
|
+
if (first === undefined) {
|
|
18
|
+
throw new Error("ReasoningRuntime: no routes configured");
|
|
19
|
+
}
|
|
20
|
+
return first;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ── Evidence ─────────────────────────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// Per-arm evidence: the mean reward AND how many samples produced it. This is
|
|
27
|
+
// structurally what `outcomeAnalytics` (@loomwork/analytics) returns, so its output
|
|
28
|
+
// feeds these routers with no conversion. The count is the half that a bare
|
|
29
|
+
// `Record<string, number>` ranking throws away — and without it a router cannot
|
|
30
|
+
// tell `1.0 from one sample` from `0.99 from ten thousand`.
|
|
31
|
+
export interface ArmStats {
|
|
32
|
+
key: string;
|
|
33
|
+
/** Mean reward. Higher = better. */
|
|
34
|
+
rank: number;
|
|
35
|
+
/** How many outcomes produced `rank`. */
|
|
36
|
+
count: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Rankings with counts (preferred) or the legacy count-free record. */
|
|
40
|
+
export type Rankings = Record<string, number> | readonly ArmStats[];
|
|
41
|
+
|
|
42
|
+
interface Arm {
|
|
43
|
+
rank: number;
|
|
44
|
+
/** undefined when the caller supplied a count-free `Record<string, number>`. */
|
|
45
|
+
count?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function indexRankings(rankings: Rankings): Map<string, Arm> {
|
|
49
|
+
if (Array.isArray(rankings)) {
|
|
50
|
+
const m = new Map<string, Arm>();
|
|
51
|
+
for (const s of rankings as readonly ArmStats[]) m.set(s.key, { rank: s.rank, count: s.count });
|
|
52
|
+
return m;
|
|
53
|
+
}
|
|
54
|
+
const m = new Map<string, Arm>();
|
|
55
|
+
for (const [key, rank] of Object.entries(rankings as Record<string, number>))
|
|
56
|
+
m.set(key, { rank });
|
|
57
|
+
return m;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RankedRouterOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Sample floor. Arms with fewer than this many outcomes are treated as UNRANKED
|
|
63
|
+
* rather than as evidence — the fix for "one lucky success at n=1 outranks a
|
|
64
|
+
* proven arm at n=10000 forever". Requires `ArmStats[]`; a count-free record
|
|
65
|
+
* throws at construction rather than silently no-opping the floor.
|
|
66
|
+
*/
|
|
67
|
+
minSamples?: number;
|
|
68
|
+
/** Router for unranked/under-sampled requests. Default: cheap-first `tagRouter`. */
|
|
69
|
+
fallback?: Router;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// M10 self-optimization — a "learned" router. Given a ranking (e.g. from
|
|
73
|
+
// @loomwork/analytics `rankModels`/`outcomeAnalytics`, mined from historical logs),
|
|
74
|
+
// prefer the highest-ranked route that exists, falling back to cheap-first
|
|
75
|
+
// (`tagRouter`) for unranked requests. Used inside ReasoningRuntime with
|
|
76
|
+
// `ctx.decide`, its choice is recorded as a durable decision (P9) — auditable and
|
|
77
|
+
// override-able, never silently autonomous (P10).
|
|
78
|
+
//
|
|
79
|
+
// PURE EXPLOIT. This router never tries an arm to learn about it: an arm absent
|
|
80
|
+
// from `rankings` can never be selected while any other arm is ranked, so a
|
|
81
|
+
// ranking mined from a log that this router itself produced will narrow over time
|
|
82
|
+
// (the arms it stops choosing stop generating outcomes). `minSamples` bounds the
|
|
83
|
+
// damage from thin evidence, but if you want the loop to keep learning, wrap it —
|
|
84
|
+
// `epsilonGreedy(rankedRouter(stats), { epsilon: 0.1 })` — or use `ucb1`, which
|
|
85
|
+
// allocates exploration by uncertainty instead of by coin flip.
|
|
86
|
+
export function rankedRouter(rankings: Rankings, opts: RankedRouterOptions = {}): Router {
|
|
87
|
+
// Index the ranking once at construction; each select is then a single O(R)
|
|
88
|
+
// max-scan instead of an O(R log R) filter+sort per call (routing decisions
|
|
89
|
+
// are on the reasoning hot path). Ties keep the tier-ascending order the
|
|
90
|
+
// caller passes — the first route holding the max rank wins, which is exactly
|
|
91
|
+
// what a stable sort's `[0]` yielded.
|
|
92
|
+
const rank = indexRankings(rankings);
|
|
93
|
+
const minSamples = opts.minSamples ?? 0;
|
|
94
|
+
const fallback = opts.fallback ?? tagRouter;
|
|
95
|
+
|
|
96
|
+
if (minSamples > 0) {
|
|
97
|
+
for (const arm of rank.values()) {
|
|
98
|
+
if (arm.count === undefined) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"rankedRouter: minSamples needs sample counts — pass ArmStats[] " +
|
|
101
|
+
"(e.g. outcomeAnalytics(events)), not a count-free rankings record"
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
select(req: ReasoningRequest, routes: ModelRoute[]): ModelRoute {
|
|
109
|
+
let best: ModelRoute | undefined;
|
|
110
|
+
let bestRank = Number.NEGATIVE_INFINITY;
|
|
111
|
+
for (const r of routes) {
|
|
112
|
+
const arm = rank.get(r.id);
|
|
113
|
+
if (arm === undefined) continue;
|
|
114
|
+
if (arm.count !== undefined && arm.count < minSamples) continue;
|
|
115
|
+
if (arm.rank > bestRank) {
|
|
116
|
+
bestRank = arm.rank;
|
|
117
|
+
best = r;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return best ?? fallback.select(req, routes);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface Ucb1Options {
|
|
126
|
+
/** Exploration weight. 1 = textbook UCB1; lower is greedier, 0 is pure exploit. */
|
|
127
|
+
exploration?: number;
|
|
128
|
+
/** Router for the cold start (no samples at all). Default: cheap-first `tagRouter`. */
|
|
129
|
+
fallback?: Router;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// UCB1 — the principled fix for the ranked router's collapse, and the recommended
|
|
133
|
+
// default for a live learning loop. Scores each arm `mean + c·sqrt(2·ln N / n)`:
|
|
134
|
+
// the bonus is large while an arm is under-sampled and shrinks as evidence
|
|
135
|
+
// accumulates, so a lucky n=1 arm cannot pin the router (its rivals keep their own
|
|
136
|
+
// large bonus until they are tried too) and an arm with NO samples scores +Inf and
|
|
137
|
+
// is guaranteed a trial. Deterministic — no RNG — which is what you want in a
|
|
138
|
+
// replay-first runtime: the same evidence always yields the same route.
|
|
139
|
+
//
|
|
140
|
+
// Assumes rewards on a comparable scale (the [0,1] of `success`/normalized `score`
|
|
141
|
+
// that `outcomeAnalytics` produces); `exploration` is the knob if yours differ.
|
|
142
|
+
export function ucb1(stats: readonly ArmStats[], opts: Ucb1Options = {}): Router {
|
|
143
|
+
const c = opts.exploration ?? 1;
|
|
144
|
+
const fallback = opts.fallback ?? tagRouter;
|
|
145
|
+
|
|
146
|
+
// Fold the evidence once at construction — per-select work is then one O(R)
|
|
147
|
+
// scan with no logarithm (`lnTotal` is loop-invariant across every select).
|
|
148
|
+
const arms = new Map<string, ArmStats>();
|
|
149
|
+
let total = 0;
|
|
150
|
+
for (const s of stats) {
|
|
151
|
+
arms.set(s.key, s);
|
|
152
|
+
total += s.count;
|
|
153
|
+
}
|
|
154
|
+
const lnTotal = Math.log(total);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
select(req: ReasoningRequest, routes: ModelRoute[]): ModelRoute {
|
|
158
|
+
if (total === 0) return fallback.select(req, routes);
|
|
159
|
+
|
|
160
|
+
let best: ModelRoute | undefined;
|
|
161
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
162
|
+
for (const r of routes) {
|
|
163
|
+
const arm = arms.get(r.id);
|
|
164
|
+
// Never sampled → must-try. Ties resolve to the first route, so a cold
|
|
165
|
+
// start walks the tier-ascending ladder one trial at a time.
|
|
166
|
+
const score =
|
|
167
|
+
arm === undefined || arm.count <= 0
|
|
168
|
+
? Number.POSITIVE_INFINITY
|
|
169
|
+
: arm.rank + c * Math.sqrt((2 * lnTotal) / arm.count);
|
|
170
|
+
if (score > bestScore) {
|
|
171
|
+
bestScore = score;
|
|
172
|
+
best = r;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return best ?? fallback.select(req, routes);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface EpsilonGreedyOptions {
|
|
181
|
+
/** Probability in [0,1] of sampling a uniformly random route instead of exploiting. */
|
|
182
|
+
epsilon: number;
|
|
183
|
+
/** Injected RNG (default `Math.random`). Pass a seeded PRNG for tests and DST runs. */
|
|
184
|
+
rng?: () => number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ε-greedy — the simplest way to stop any exploit-only router from starving its
|
|
188
|
+
// own evidence: with probability ε ignore `inner` and sample a route uniformly.
|
|
189
|
+
//
|
|
190
|
+
// The randomness is replay-safe WHERE IT MATTERS: ReasoningRuntime resolves the
|
|
191
|
+
// route inside `ctx.decide`, so the sampled id is committed as DECISION_RECORDED
|
|
192
|
+
// and replay serves that recording back — `select` is never called again and the
|
|
193
|
+
// RNG is never re-rolled. Standalone callers (no `decide` sink) get no such
|
|
194
|
+
// guarantee; inject `rng` if you need them reproducible.
|
|
195
|
+
export function epsilonGreedy(inner: Router, opts: EpsilonGreedyOptions): Router {
|
|
196
|
+
const { epsilon } = opts;
|
|
197
|
+
if (!(epsilon >= 0 && epsilon <= 1)) {
|
|
198
|
+
throw new Error(`epsilonGreedy: epsilon must be in [0,1], got ${epsilon}`);
|
|
199
|
+
}
|
|
200
|
+
const rng = opts.rng ?? Math.random;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
select(req: ReasoningRequest, routes: ModelRoute[]): ModelRoute {
|
|
204
|
+
if (routes.length > 0 && rng() < epsilon) {
|
|
205
|
+
const picked = routes[Math.min(routes.length - 1, Math.floor(rng() * routes.length))];
|
|
206
|
+
if (picked !== undefined) return picked;
|
|
207
|
+
}
|
|
208
|
+
return inner.select(req, routes);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A fixed router that always selects a specific model id — handy for tests and for
|
|
214
|
+
// pinning a strategy to one model.
|
|
215
|
+
export function fixedRouter(modelId: string): Router {
|
|
216
|
+
return {
|
|
217
|
+
select(_req, routes) {
|
|
218
|
+
const route = routes.find((r) => r.id === modelId);
|
|
219
|
+
if (route === undefined) {
|
|
220
|
+
throw new Error(`ReasoningRuntime: fixedRouter target "${modelId}" not in routes`);
|
|
221
|
+
}
|
|
222
|
+
return route;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { LlmProvider, LlmResponse } from "@loomwork/llm";
|
|
2
|
+
import { bench, describe } from "vitest";
|
|
3
|
+
import { ReasoningRuntime } from "./ReasoningRuntime";
|
|
4
|
+
import { tagRouter } from "./router";
|
|
5
|
+
import type { ConfidenceSignal, ModelRoute } from "./types";
|
|
6
|
+
|
|
7
|
+
// Benches for the two routing hot paths made O(1)/O(n):
|
|
8
|
+
// 1. tagRouter no longer re-sorts the route list on every select().
|
|
9
|
+
// 2. Elevation hops via a precomputed next-tier index instead of an O(n) scan,
|
|
10
|
+
// so walking a deep ladder is O(n) not O(n^2).
|
|
11
|
+
|
|
12
|
+
class FakeProvider implements LlmProvider {
|
|
13
|
+
constructor(private readonly res: LlmResponse) {}
|
|
14
|
+
async complete(): Promise<LlmResponse> {
|
|
15
|
+
return this.res;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const weak: LlmResponse = { content: "weak", stopReason: "max_tokens" };
|
|
20
|
+
// Force elevation all the way up the ladder every call.
|
|
21
|
+
const alwaysLow: ConfidenceSignal = { score: () => 0 };
|
|
22
|
+
|
|
23
|
+
function makeRoutes(n: number): ModelRoute[] {
|
|
24
|
+
const provider = new FakeProvider(weak);
|
|
25
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
26
|
+
id: `m${i}`,
|
|
27
|
+
provider,
|
|
28
|
+
tier: i,
|
|
29
|
+
...(i === 0 && { tags: ["planning"] }),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const routes = makeRoutes(64);
|
|
34
|
+
|
|
35
|
+
describe("tagRouter.select over 64 routes", () => {
|
|
36
|
+
const req = { messages: [{ role: "user" as const, content: "hi" }], strategy: "planning" };
|
|
37
|
+
|
|
38
|
+
bench("re-sort baseline (previous shape)", () => {
|
|
39
|
+
const sorted = [...routes].sort((a, b) => a.tier - b.tier);
|
|
40
|
+
sorted.find((r) => r.tags?.includes("planning"));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
bench("tagRouter.select (pre-sorted, no re-sort)", () => {
|
|
44
|
+
tagRouter.select(req, routes);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("reason() full elevation up a 64-tier ladder", () => {
|
|
49
|
+
const runtime = new ReasoningRuntime({ routes, confidence: alwaysLow });
|
|
50
|
+
|
|
51
|
+
bench("reason (O(1) tier hops)", async () => {
|
|
52
|
+
await runtime.reason({ messages: [{ role: "user", content: "hi" }] });
|
|
53
|
+
});
|
|
54
|
+
});
|