Skip to content

Commit 8b1180b

Browse files
committed
feat: Implement embedding provider resolver and runner; add tests and update execution plan
1 parent fa746a2 commit 8b1180b

7 files changed

Lines changed: 529 additions & 16 deletions

File tree

CORTEX-DESIGN-PLAN-TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ Completed since the prior snapshot:
3232
5. Runtime helper resolves model metadata and derives routing policy in one call (`resolveRoutingPolicyForModel` in `Policy.ts`).
3333
6. Deterministic dummy SHA-256 embedder exists for pre-model hotpath testing (`embeddings/DeterministicDummyEmbeddingBackend.ts`).
3434
7. Benchmark harness exists for dummy embedder throughput baselining (`npm run benchmark:dummy`).
35+
8. Baseline adaptive provider selection exists with capability filtering + benchmark-based winner choice (`embeddings/ProviderResolver.ts`, `embeddings/EmbeddingRunner.ts`).
3536

3637
Next focus:
3738
1. Wire resolved model profiles into runtime ingest/query entry points.
38-
2. Build embedding provider resolver + runner modules and associated tests.
39+
2. Add real embedding providers (ONNX/Transformers/WebNN/WebGPU/WebGL/WASM) to resolver candidate sets.
3940
3. Add browser/electron runtime test lanes to match merge-gate policy.
4041

4142
## 1. Design

PROJECT-EXECUTION-PLAN.md

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,25 +31,30 @@ Completed in this pass:
3131
9. Added executable dummy hotpath benchmark harness:
3232
- `tests/benchmarks/DummyEmbedderHotpath.bench.ts`
3333
- `npm run benchmark:dummy`
34+
10. Implemented baseline embedding runtime selection modules:
35+
- `embeddings/ProviderResolver.ts`
36+
- `embeddings/EmbeddingRunner.ts`
37+
- tests: `tests/embeddings/ProviderResolver.test.ts`, `tests/embeddings/EmbeddingRunner.test.ts`
38+
- selection now supports capability filtering + benchmark-based winner choice
3439

3540
Open items carried to next pass:
3641
1. Wire resolved `ModelProfile` into first concrete ingest/query orchestrator path (once those runtime modules are added).
37-
2. Add embedding provider resolver/runner modules and connect fallback policy to runtime execution.
42+
2. Add real embedding providers (ONNX/Transformers/WebNN/WebGPU/WebGL/WASM) as candidates for the resolver.
3843
3. Add browser/electron runtime scripts and CI lanes for non-Node merge gating.
3944

4045
## Next Session Highest Priority (P0)
4146

42-
Integrate model-profile ownership into runtime flows and start the embedding vertical slice.
47+
Connect adaptive embedding selection to runtime orchestration and add real provider candidates.
4348

4449
Instruction:
4550
1. Use `ModelProfileResolver` at runtime boundaries before any policy derivation or embedding execution.
46-
2. Implement first embedding runner/provider resolver slice with fallback semantics.
51+
2. Register real embedding providers in `ProviderResolver` candidate lists.
4752
3. Keep strict TDD (Red -> Green -> Refactor).
4853
4. If a blocker appears, record it in this document under an error log entry and continue with the next actionable slice.
4954

5055
Definition of done for this pass:
5156
1. Runtime path resolves model metadata through `ModelProfileResolver` before use.
52-
2. Embedding provider resolver tests and implementation are present.
57+
2. At least one non-dummy real provider can be selected by capability + benchmark policy.
5358
3. Any unresolved blocker is documented with file/symptom/next action.
5459

5560
## Non-Negotiable Rules
@@ -69,8 +74,8 @@ Definition of done for this pass:
6974
- `core/ModelDefaults.ts`
7075
4. ~~Replace hardcoded model-dependent values with `ModelProfile` lookups.~~ ✅ Done for current code paths (2026-03-11)
7176
5. Implement embedding runner with fallback chain and telemetry:
72-
- `embeddings/EmbeddingRunner.ts`
73-
- `embeddings/ProviderResolver.ts`
77+
- `embeddings/EmbeddingRunner.ts` ✅ baseline done (2026-03-11)
78+
- `embeddings/ProviderResolver.ts` ✅ baseline done (2026-03-11)
7479
- `embeddings/TransformersEmbeddingBackend.ts`
7580
- `embeddings/OrtWebglEmbeddingBackend.ts`
7681
- `embeddings/OnnxEmbeddingRunner.ts`
@@ -102,16 +107,17 @@ Available now:
102107
3. `npm run test:unit -- tests/model/ModelDefaults.test.ts`
103108
4. `npm run guard:model-derived`
104109
5. `npm run test:unit -- tests/embeddings/DeterministicDummyEmbeddingBackend.test.ts`
105-
6. `npm run benchmark:dummy`
106-
7. `npm run benchmark`
107-
8. `npm run build && npm run lint`
110+
6. `npm run test:unit -- tests/embeddings/ProviderResolver.test.ts`
111+
7. `npm run test:unit -- tests/embeddings/EmbeddingRunner.test.ts`
112+
8. `npm run benchmark:dummy`
113+
9. `npm run benchmark`
114+
10. `npm run build && npm run lint`
108115

109116
Planned commands to add in later passes:
110-
1. `npm run test:unit -- tests/embeddings/ProviderResolver.test.ts`
111-
2. `npm run test:unit -- tests/embeddings/OnnxEmbeddingRunner.test.ts`
112-
3. `npm run test:browser`
113-
4. `npm run test:electron`
114-
5. `npm run test:all`
117+
1. `npm run test:unit -- tests/embeddings/OnnxEmbeddingRunner.test.ts`
118+
2. `npm run test:browser`
119+
3. `npm run test:electron`
120+
4. `npm run test:all`
115121

116122
## Known Hardcoded Hotspots To Clean First
117123

embeddings/EmbeddingRunner.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { EmbeddingBackend } from "./EmbeddingBackend";
2+
import {
3+
type ResolveEmbeddingBackendOptions,
4+
type ResolvedEmbeddingBackend,
5+
resolveEmbeddingBackend,
6+
} from "./ProviderResolver";
7+
8+
export type ResolveEmbeddingSelection = () => Promise<ResolvedEmbeddingBackend>;
9+
10+
export class EmbeddingRunner {
11+
private selectionPromise: Promise<ResolvedEmbeddingBackend> | undefined;
12+
private resolvedSelection: ResolvedEmbeddingBackend | undefined;
13+
14+
constructor(private readonly resolveSelection: ResolveEmbeddingSelection) {}
15+
16+
static fromResolverOptions(
17+
options: ResolveEmbeddingBackendOptions,
18+
): EmbeddingRunner {
19+
return new EmbeddingRunner(() => resolveEmbeddingBackend(options));
20+
}
21+
22+
get selectedKind(): string | undefined {
23+
return this.resolvedSelection?.selectedKind;
24+
}
25+
26+
async getSelection(): Promise<ResolvedEmbeddingBackend> {
27+
return this.ensureSelection();
28+
}
29+
30+
async getBackend(): Promise<EmbeddingBackend> {
31+
const selection = await this.ensureSelection();
32+
return selection.backend;
33+
}
34+
35+
async embed(texts: string[]): Promise<Float32Array[]> {
36+
const backend = await this.getBackend();
37+
return backend.embed(texts);
38+
}
39+
40+
private async ensureSelection(): Promise<ResolvedEmbeddingBackend> {
41+
if (this.resolvedSelection) {
42+
return this.resolvedSelection;
43+
}
44+
45+
if (!this.selectionPromise) {
46+
this.selectionPromise = this.resolveSelection().then((selection) => {
47+
this.resolvedSelection = selection;
48+
return selection;
49+
});
50+
}
51+
52+
return this.selectionPromise;
53+
}
54+
}

embeddings/ProviderResolver.ts

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import {
2+
DeterministicDummyEmbeddingBackend,
3+
type DeterministicDummyEmbeddingBackendOptions,
4+
} from "./DeterministicDummyEmbeddingBackend";
5+
import type { EmbeddingBackend } from "./EmbeddingBackend";
6+
7+
export type EmbeddingProviderKind =
8+
| "webnn"
9+
| "webgpu"
10+
| "webgl"
11+
| "wasm"
12+
| "dummy"
13+
| (string & {});
14+
15+
export interface EmbeddingProviderCandidate {
16+
kind: EmbeddingProviderKind;
17+
isSupported: () => boolean | Promise<boolean>;
18+
createBackend: () => EmbeddingBackend | Promise<EmbeddingBackend>;
19+
}
20+
21+
export interface EmbeddingProviderBenchmarkPolicy {
22+
enabled: boolean;
23+
warmupRuns: number;
24+
timedRuns: number;
25+
sampleTexts: string[];
26+
}
27+
28+
export interface EmbeddingProviderMeasurement {
29+
kind: EmbeddingProviderKind;
30+
meanMs: number;
31+
}
32+
33+
export type EmbeddingProviderResolveReason =
34+
| "forced"
35+
| "benchmark"
36+
| "capability-order";
37+
38+
export interface ResolvedEmbeddingBackend {
39+
backend: EmbeddingBackend;
40+
selectedKind: EmbeddingProviderKind;
41+
reason: EmbeddingProviderResolveReason;
42+
supportedKinds: EmbeddingProviderKind[];
43+
measurements: EmbeddingProviderMeasurement[];
44+
}
45+
46+
export const DEFAULT_PROVIDER_ORDER: ReadonlyArray<EmbeddingProviderKind> =
47+
Object.freeze([
48+
"webnn",
49+
"webgpu",
50+
"webgl",
51+
"wasm",
52+
"dummy",
53+
]);
54+
55+
export const DEFAULT_PROVIDER_BENCHMARK_POLICY: EmbeddingProviderBenchmarkPolicy =
56+
Object.freeze({
57+
enabled: true,
58+
warmupRuns: 1,
59+
timedRuns: 3,
60+
sampleTexts: [
61+
"cortex benchmark probe",
62+
"routing and coherence warmup",
63+
"deterministic provider timing",
64+
],
65+
});
66+
67+
export type BenchmarkBackendFn = (
68+
backend: EmbeddingBackend,
69+
policy: EmbeddingProviderBenchmarkPolicy,
70+
) => Promise<number>;
71+
72+
export interface ResolveEmbeddingBackendOptions {
73+
candidates: EmbeddingProviderCandidate[];
74+
preferredOrder?: ReadonlyArray<EmbeddingProviderKind>;
75+
forceKind?: EmbeddingProviderKind;
76+
benchmark?: Partial<EmbeddingProviderBenchmarkPolicy>;
77+
benchmarkBackend?: BenchmarkBackendFn;
78+
}
79+
80+
function assertPositiveInteger(name: string, value: number): void {
81+
if (!Number.isInteger(value) || value <= 0) {
82+
throw new Error(`${name} must be a positive integer`);
83+
}
84+
}
85+
86+
function validateBenchmarkPolicy(
87+
policy: EmbeddingProviderBenchmarkPolicy,
88+
): EmbeddingProviderBenchmarkPolicy {
89+
assertPositiveInteger("warmupRuns", policy.warmupRuns);
90+
assertPositiveInteger("timedRuns", policy.timedRuns);
91+
92+
if (policy.sampleTexts.length === 0) {
93+
throw new Error("sampleTexts must not be empty");
94+
}
95+
96+
return policy;
97+
}
98+
99+
function nowMs(): number {
100+
const perfNow = globalThis.performance?.now?.bind(globalThis.performance);
101+
if (perfNow) {
102+
return perfNow();
103+
}
104+
return Date.now();
105+
}
106+
107+
async function defaultBenchmarkBackend(
108+
backend: EmbeddingBackend,
109+
policy: EmbeddingProviderBenchmarkPolicy,
110+
): Promise<number> {
111+
for (let i = 0; i < policy.warmupRuns; i++) {
112+
await backend.embed(policy.sampleTexts);
113+
}
114+
115+
let totalMs = 0;
116+
for (let i = 0; i < policy.timedRuns; i++) {
117+
const start = nowMs();
118+
await backend.embed(policy.sampleTexts);
119+
totalMs += nowMs() - start;
120+
}
121+
122+
return totalMs / policy.timedRuns;
123+
}
124+
125+
function orderCandidates(
126+
candidates: EmbeddingProviderCandidate[],
127+
preferredOrder: ReadonlyArray<EmbeddingProviderKind>,
128+
): EmbeddingProviderCandidate[] {
129+
const orderIndex = new Map<EmbeddingProviderKind, number>();
130+
for (let i = 0; i < preferredOrder.length; i++) {
131+
orderIndex.set(preferredOrder[i], i);
132+
}
133+
134+
return [...candidates].sort((a, b) => {
135+
const aIndex = orderIndex.get(a.kind) ?? Number.MAX_SAFE_INTEGER;
136+
const bIndex = orderIndex.get(b.kind) ?? Number.MAX_SAFE_INTEGER;
137+
return aIndex - bIndex;
138+
});
139+
}
140+
141+
export async function resolveEmbeddingBackend(
142+
options: ResolveEmbeddingBackendOptions,
143+
): Promise<ResolvedEmbeddingBackend> {
144+
const preferredOrder = options.preferredOrder ?? DEFAULT_PROVIDER_ORDER;
145+
const benchmarkPolicy = validateBenchmarkPolicy({
146+
...DEFAULT_PROVIDER_BENCHMARK_POLICY,
147+
...options.benchmark,
148+
});
149+
150+
const capabilityChecks = await Promise.all(
151+
options.candidates.map(async (candidate) => ({
152+
candidate,
153+
supported: await candidate.isSupported(),
154+
})),
155+
);
156+
157+
if (options.forceKind !== undefined) {
158+
const forcedEntry = capabilityChecks.find(
159+
(entry) => entry.candidate.kind === options.forceKind,
160+
);
161+
162+
if (!forcedEntry || !forcedEntry.supported) {
163+
throw new Error(`Forced provider ${options.forceKind} is not supported`);
164+
}
165+
166+
return {
167+
backend: await forcedEntry.candidate.createBackend(),
168+
selectedKind: forcedEntry.candidate.kind,
169+
reason: "forced",
170+
supportedKinds: orderCandidates(
171+
capabilityChecks
172+
.filter((entry) => entry.supported)
173+
.map((entry) => entry.candidate),
174+
preferredOrder,
175+
).map((candidate) => candidate.kind),
176+
measurements: [],
177+
};
178+
}
179+
180+
const supportedCandidates = orderCandidates(
181+
capabilityChecks
182+
.filter((entry) => entry.supported)
183+
.map((entry) => entry.candidate),
184+
preferredOrder,
185+
);
186+
187+
if (supportedCandidates.length === 0) {
188+
throw new Error("No supported embedding providers are available");
189+
}
190+
191+
const supportedKinds = supportedCandidates.map((candidate) => candidate.kind);
192+
193+
const benchmarkBackend = options.benchmarkBackend ?? defaultBenchmarkBackend;
194+
if (benchmarkPolicy.enabled) {
195+
const measurements: {
196+
candidate: EmbeddingProviderCandidate;
197+
backend: EmbeddingBackend;
198+
meanMs: number;
199+
}[] = [];
200+
201+
for (const candidate of supportedCandidates) {
202+
const backend = await candidate.createBackend();
203+
const meanMs = await benchmarkBackend(backend, benchmarkPolicy);
204+
measurements.push({ candidate, backend, meanMs });
205+
}
206+
207+
let winner = measurements[0];
208+
for (let i = 1; i < measurements.length; i++) {
209+
if (measurements[i].meanMs < winner.meanMs) {
210+
winner = measurements[i];
211+
}
212+
}
213+
214+
return {
215+
backend: winner.backend,
216+
selectedKind: winner.candidate.kind,
217+
reason: "benchmark",
218+
supportedKinds,
219+
measurements: measurements.map((m) => ({
220+
kind: m.candidate.kind,
221+
meanMs: m.meanMs,
222+
})),
223+
};
224+
}
225+
226+
const selectedCandidate = supportedCandidates[0];
227+
return {
228+
backend: await selectedCandidate.createBackend(),
229+
selectedKind: selectedCandidate.kind,
230+
reason: "capability-order",
231+
supportedKinds,
232+
measurements: [],
233+
};
234+
}
235+
236+
export function createDummyProviderCandidate(
237+
options: DeterministicDummyEmbeddingBackendOptions = {},
238+
): EmbeddingProviderCandidate {
239+
return {
240+
kind: "dummy",
241+
isSupported: () => globalThis.crypto?.subtle !== undefined,
242+
createBackend: () => new DeterministicDummyEmbeddingBackend(options),
243+
};
244+
}

0 commit comments

Comments
 (0)