@danypops/jittor 0.5.0 → 0.6.0
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/README.md +51 -7
- package/docs/BENCHMARK_SOURCES.md +32 -0
- package/docs/OUTPUT_CHANNELS.md +31 -0
- package/docs/PROVIDER_RESEARCH.md +38 -0
- package/extension/src/benchmark-tui.ts +103 -0
- package/extension/src/footer.ts +43 -3
- package/extension/src/index.ts +196 -33
- package/extension/src/tui.ts +32 -7
- package/extension/src/usage.ts +147 -38
- package/package.json +4 -1
- package/src/adapters/metric-benchmark-store.ts +99 -0
- package/src/adapters/openrouter-benchmark-index-source.ts +93 -0
- package/src/adapters/openrouter-benchmark-source.ts +109 -0
- package/src/cli.ts +531 -9
- package/src/constants.ts +38 -1
- package/src/daemon.ts +23 -2
- package/src/domain/benchmark.ts +264 -0
- package/src/domain/context-telemetry.ts +31 -0
- package/src/domain/metric.ts +46 -5
- package/src/domain/model-observation.ts +181 -0
- package/src/domain/model-ranking-service.ts +40 -0
- package/src/domain/model-ranking.ts +213 -0
- package/src/domain/usage.ts +84 -7
- package/src/ports/benchmark-controller.ts +11 -0
- package/src/ports/benchmark-source.ts +7 -0
- package/src/ports/benchmark-store.ts +7 -0
- package/src/ports/router-controller.ts +1 -0
- package/src/providers/anthropic-contracts.ts +116 -0
- package/src/providers/google-vertex-contracts.ts +104 -0
- package/src/router.ts +12 -0
- package/src/service.ts +60 -3
package/src/daemon.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { LOOPBACK_HOST, MAINTENANCE_INTERVAL_MS, TELEMETRY_POLL_INTERVAL_MS } from "./constants.ts";
|
|
2
2
|
import { DEFAULT_POLICY, UNCONFIGURED_ROUTE } from "./config.ts";
|
|
3
3
|
import { SQLiteMetricStore } from "./adapters/sqlite-metric-store.ts";
|
|
4
|
+
import { MetricBenchmarkStore } from "./adapters/metric-benchmark-store.ts";
|
|
5
|
+
import { OpenRouterBenchmarkIndexSource } from "./adapters/openrouter-benchmark-index-source.ts";
|
|
6
|
+
import { OpenRouterBenchmarkSource } from "./adapters/openrouter-benchmark-source.ts";
|
|
4
7
|
import { openJittorDb } from "./db.ts";
|
|
8
|
+
import { BenchmarkCatalog } from "./domain/benchmark.ts";
|
|
9
|
+
import { EvidenceModelRanker } from "./domain/model-ranking-service.ts";
|
|
5
10
|
import { createApp, JittorService } from "./service.ts";
|
|
6
11
|
import { JittorRouter } from "./router.ts";
|
|
12
|
+
import type { BenchmarkSource } from "./ports/benchmark-source.ts";
|
|
7
13
|
import type { TelemetrySource } from "./ports/telemetry-source.ts";
|
|
8
14
|
import { CodexTelemetrySource, OpenRouterTelemetrySource } from "./providers/telemetry-sources.ts";
|
|
9
15
|
import {
|
|
@@ -20,6 +26,13 @@ export interface RunningDaemon {
|
|
|
20
26
|
stop(): Promise<void>;
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
export function benchmarkSourcesFromEnvironment(env: Record<string, string | undefined> = process.env): BenchmarkSource[] {
|
|
30
|
+
if (env["JITTOR_OPENROUTER_BENCHMARKS"] !== "1") return [];
|
|
31
|
+
const sources: BenchmarkSource[] = [new OpenRouterBenchmarkSource()];
|
|
32
|
+
if (env["OPENROUTER_API_KEY"]) sources.push(new OpenRouterBenchmarkIndexSource(env["OPENROUTER_API_KEY"]));
|
|
33
|
+
return sources;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
export function telemetrySourcesFromEnvironment(env: Record<string, string | undefined> = process.env): TelemetrySource[] {
|
|
24
37
|
const sources: TelemetrySource[] = [];
|
|
25
38
|
const codexAuthFile = env["JITTOR_CODEX_AUTH_FILE"];
|
|
@@ -36,6 +49,10 @@ export function startDaemon(
|
|
|
36
49
|
const token = ensureAuthToken(paths);
|
|
37
50
|
const metrics = new SQLiteMetricStore(openJittorDb(paths.database));
|
|
38
51
|
const sources = telemetrySourcesFromEnvironment(env);
|
|
52
|
+
const benchmarkSources = benchmarkSourcesFromEnvironment(env);
|
|
53
|
+
const benchmarkStore = new MetricBenchmarkStore(metrics);
|
|
54
|
+
const benchmarks = new BenchmarkCatalog(benchmarkStore, benchmarkSources);
|
|
55
|
+
const modelRanker = new EvidenceModelRanker(benchmarkStore, metrics);
|
|
39
56
|
const router = new JittorRouter({
|
|
40
57
|
metrics,
|
|
41
58
|
sources,
|
|
@@ -43,7 +60,7 @@ export function startDaemon(
|
|
|
43
60
|
routes: [],
|
|
44
61
|
currentRoute: UNCONFIGURED_ROUTE,
|
|
45
62
|
});
|
|
46
|
-
const service = new JittorService(metrics, router);
|
|
63
|
+
const service = new JittorService(metrics, router, benchmarks, modelRanker);
|
|
47
64
|
const app = createApp({ service, token });
|
|
48
65
|
const server = Bun.serve({
|
|
49
66
|
hostname: LOOPBACK_HOST,
|
|
@@ -57,9 +74,13 @@ export function startDaemon(
|
|
|
57
74
|
throw new Error("Jittor daemon failed to bind a loopback port");
|
|
58
75
|
}
|
|
59
76
|
writeDaemonHandle(paths, { host: LOOPBACK_HOST, port, pid: process.pid });
|
|
60
|
-
const maintenance = setInterval(() => {
|
|
77
|
+
const maintenance = setInterval(() => {
|
|
78
|
+
void service.execute("service.checkpoint", {});
|
|
79
|
+
void benchmarks.refresh();
|
|
80
|
+
}, MAINTENANCE_INTERVAL_MS);
|
|
61
81
|
const poll = setInterval(() => { void router.poll(); }, TELEMETRY_POLL_INTERVAL_MS);
|
|
62
82
|
if (sources.length > 0) void router.poll();
|
|
83
|
+
if (benchmarkSources.length > 0) void benchmarks.refresh();
|
|
63
84
|
let stopped = false;
|
|
64
85
|
return {
|
|
65
86
|
host: LOOPBACK_HOST,
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BENCHMARK_DEFAULT_QUERY_LIMIT,
|
|
3
|
+
BENCHMARK_IDENTITY_MAX_CHARACTERS,
|
|
4
|
+
BENCHMARK_MAX_OBSERVATIONS_PER_SNAPSHOT,
|
|
5
|
+
BENCHMARK_MAX_QUERY_LIMIT,
|
|
6
|
+
BENCHMARK_MAX_TEXT_CHARACTERS,
|
|
7
|
+
BENCHMARK_REFRESH_INTERVAL_MS,
|
|
8
|
+
} from "../constants.ts";
|
|
9
|
+
import { METRIC_UNITS, type MetricUnit } from "./metric.ts";
|
|
10
|
+
import type { BenchmarkController } from "../ports/benchmark-controller.ts";
|
|
11
|
+
import type { BenchmarkSource as BenchmarkSourcePort } from "../ports/benchmark-source.ts";
|
|
12
|
+
import type { BenchmarkStore } from "../ports/benchmark-store.ts";
|
|
13
|
+
|
|
14
|
+
export type BenchmarkSourceType = "creator" | "marketplace" | "independent" | "operational" | "preference" | "local";
|
|
15
|
+
|
|
16
|
+
export interface ModelIdentity {
|
|
17
|
+
provider: string;
|
|
18
|
+
model: string;
|
|
19
|
+
version: string | null;
|
|
20
|
+
canonical: string;
|
|
21
|
+
aliases: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BenchmarkProvenance {
|
|
25
|
+
sourceId: string;
|
|
26
|
+
sourceType: BenchmarkSourceType;
|
|
27
|
+
publisher: string;
|
|
28
|
+
url: string;
|
|
29
|
+
revision: string;
|
|
30
|
+
publishedAt: number | null;
|
|
31
|
+
retrievedAt: number;
|
|
32
|
+
freshUntil: number;
|
|
33
|
+
license: string;
|
|
34
|
+
confidence: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BenchmarkObservation {
|
|
38
|
+
model: ModelIdentity;
|
|
39
|
+
dimension: string;
|
|
40
|
+
value: number;
|
|
41
|
+
unit: MetricUnit;
|
|
42
|
+
provenance: BenchmarkProvenance;
|
|
43
|
+
methodology: Record<string, string | number | boolean | null | string[]>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BenchmarkSourceSnapshot {
|
|
47
|
+
sourceId: string;
|
|
48
|
+
snapshotId: string;
|
|
49
|
+
retrievedAt: number;
|
|
50
|
+
observations: BenchmarkObservation[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BenchmarkSnapshot extends BenchmarkSourceSnapshot {
|
|
54
|
+
publishedAt: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BenchmarkSourceStatus {
|
|
58
|
+
id: string;
|
|
59
|
+
ok: boolean | null;
|
|
60
|
+
hasEvidence: boolean;
|
|
61
|
+
lastAttemptAt: number | null;
|
|
62
|
+
lastSuccessAt: number | null;
|
|
63
|
+
observations: number;
|
|
64
|
+
error?: "source refresh failed";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface BenchmarkRefreshResult {
|
|
68
|
+
observedAt: number;
|
|
69
|
+
sources: BenchmarkSourceStatus[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface BenchmarkQuery {
|
|
73
|
+
sourceId: string;
|
|
74
|
+
model?: string;
|
|
75
|
+
dimension?: string;
|
|
76
|
+
limit?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface BenchmarkQueryResult extends BenchmarkSnapshot {
|
|
80
|
+
completeness: "complete" | "truncated";
|
|
81
|
+
freshness: "fresh" | "stale";
|
|
82
|
+
freshUntil: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface BenchmarkCatalogOptions {
|
|
86
|
+
clock?: () => number;
|
|
87
|
+
refreshIntervalMs?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type BenchmarkSource = BenchmarkSourcePort;
|
|
91
|
+
|
|
92
|
+
const VERSION_SUFFIX = /-(\d{4}-\d{2}-\d{2})$/;
|
|
93
|
+
const SOURCE_TYPES = new Set<BenchmarkSourceType>(["creator", "marketplace", "independent", "operational", "preference", "local"]);
|
|
94
|
+
|
|
95
|
+
function boundedText(value: unknown, name: string, maximum = BENCHMARK_MAX_TEXT_CHARACTERS): string {
|
|
96
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${name} is required`);
|
|
97
|
+
const normalized = value.trim();
|
|
98
|
+
if (normalized.length > maximum) throw new Error(`${name} exceeds the length limit`);
|
|
99
|
+
if (/\p{Cc}/u.test(normalized)) throw new Error(`${name} contains control characters`);
|
|
100
|
+
return normalized;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function identityPart(value: string, name: string, allowPath = false): string {
|
|
104
|
+
const normalized = boundedText(value, name, BENCHMARK_IDENTITY_MAX_CHARACTERS).toLowerCase();
|
|
105
|
+
const pattern = allowPath ? /^[a-z0-9][a-z0-9._:+/-]*$/ : /^[a-z0-9][a-z0-9._:+-]*$/;
|
|
106
|
+
if (!pattern.test(normalized) || normalized.includes("//")) throw new Error(`${name} is invalid`);
|
|
107
|
+
return normalized;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function normalizeModelIdentity(provider: string, model: string, aliases: string[] = []): ModelIdentity {
|
|
111
|
+
const normalizedProvider = identityPart(provider, "provider");
|
|
112
|
+
const normalizedModel = identityPart(model, "model", true);
|
|
113
|
+
const version = VERSION_SUFFIX.exec(normalizedModel)?.[1] ?? null;
|
|
114
|
+
const canonical = `${normalizedProvider}/${normalizedModel}`;
|
|
115
|
+
const normalizedAliases = [...new Set(aliases.map((alias) => boundedText(alias, "alias", BENCHMARK_IDENTITY_MAX_CHARACTERS).toLowerCase()))]
|
|
116
|
+
.filter((alias) => alias !== canonical)
|
|
117
|
+
.sort();
|
|
118
|
+
return { provider: normalizedProvider, model: normalizedModel, version, canonical, aliases: normalizedAliases };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validateIdentity(value: unknown): ModelIdentity {
|
|
122
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("model identity is required");
|
|
123
|
+
const input = value as Record<string, unknown>;
|
|
124
|
+
if (!Array.isArray(input["aliases"]) || !input["aliases"].every((alias) => typeof alias === "string")) throw new Error("model aliases are invalid");
|
|
125
|
+
const normalized = normalizeModelIdentity(String(input["provider"] ?? ""), String(input["model"] ?? ""), input["aliases"] as string[]);
|
|
126
|
+
if (input["canonical"] !== normalized.canonical || input["version"] !== normalized.version) throw new Error("model identity is not normalized");
|
|
127
|
+
return normalized;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function validateTimestamp(value: unknown, name: string, nullable = false): number | null {
|
|
131
|
+
if (nullable && value === null) return null;
|
|
132
|
+
if (!Number.isSafeInteger(value) || (value as number) <= 0) throw new Error(`${name} must be a positive integer timestamp`);
|
|
133
|
+
return value as number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateProvenance(value: unknown): BenchmarkProvenance {
|
|
137
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("provenance is required");
|
|
138
|
+
const input = value as Record<string, unknown>;
|
|
139
|
+
const sourceId = identityPart(String(input["sourceId"] ?? ""), "source id");
|
|
140
|
+
if (!SOURCE_TYPES.has(input["sourceType"] as BenchmarkSourceType)) throw new Error("source type is invalid");
|
|
141
|
+
let url: URL;
|
|
142
|
+
try { url = new URL(boundedText(input["url"], "source URL")); } catch { throw new Error("source URL is invalid"); }
|
|
143
|
+
if (url.protocol !== "https:") throw new Error("source URL must use HTTPS");
|
|
144
|
+
const confidence = input["confidence"];
|
|
145
|
+
if (typeof confidence !== "number" || !Number.isFinite(confidence) || confidence < 0 || confidence > 1) throw new Error("confidence must be between zero and one");
|
|
146
|
+
const retrievedAt = validateTimestamp(input["retrievedAt"], "retrieval time") as number;
|
|
147
|
+
const freshUntil = validateTimestamp(input["freshUntil"], "freshness deadline") as number;
|
|
148
|
+
if (freshUntil < retrievedAt) throw new Error("freshness deadline precedes retrieval");
|
|
149
|
+
return {
|
|
150
|
+
sourceId,
|
|
151
|
+
sourceType: input["sourceType"] as BenchmarkSourceType,
|
|
152
|
+
publisher: boundedText(input["publisher"], "publisher"),
|
|
153
|
+
url: url.toString(),
|
|
154
|
+
revision: boundedText(input["revision"], "revision"),
|
|
155
|
+
publishedAt: validateTimestamp(input["publishedAt"], "publication time", true),
|
|
156
|
+
retrievedAt,
|
|
157
|
+
freshUntil,
|
|
158
|
+
license: boundedText(input["license"], "license"),
|
|
159
|
+
confidence,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateMethodology(value: unknown): BenchmarkObservation["methodology"] {
|
|
164
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("methodology is required");
|
|
165
|
+
const serialized = JSON.stringify(value);
|
|
166
|
+
if (serialized.length > BENCHMARK_MAX_TEXT_CHARACTERS) throw new Error("methodology exceeds the size limit");
|
|
167
|
+
return structuredClone(value as BenchmarkObservation["methodology"]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function validateBenchmarkObservation(value: unknown): BenchmarkObservation {
|
|
171
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("benchmark observation must be an object");
|
|
172
|
+
const input = value as Record<string, unknown>;
|
|
173
|
+
const numericValue = input["value"];
|
|
174
|
+
if (typeof numericValue !== "number" || !Number.isFinite(numericValue)) throw new Error("benchmark value must be finite");
|
|
175
|
+
if (!METRIC_UNITS.includes(input["unit"] as MetricUnit)) throw new Error("benchmark unit is not supported");
|
|
176
|
+
return {
|
|
177
|
+
model: validateIdentity(input["model"]),
|
|
178
|
+
dimension: identityPart(String(input["dimension"] ?? ""), "dimension"),
|
|
179
|
+
value: numericValue,
|
|
180
|
+
unit: input["unit"] as MetricUnit,
|
|
181
|
+
provenance: validateProvenance(input["provenance"]),
|
|
182
|
+
methodology: validateMethodology(input["methodology"]),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateSourceSnapshot(value: BenchmarkSourceSnapshot, expectedSourceId: string): BenchmarkSourceSnapshot {
|
|
187
|
+
const sourceId = identityPart(value.sourceId, "source id");
|
|
188
|
+
if (sourceId !== expectedSourceId) throw new Error("source snapshot identity mismatch");
|
|
189
|
+
const snapshotId = boundedText(value.snapshotId, "snapshot id", BENCHMARK_IDENTITY_MAX_CHARACTERS);
|
|
190
|
+
const retrievedAt = validateTimestamp(value.retrievedAt, "retrieval time") as number;
|
|
191
|
+
if (!Array.isArray(value.observations) || value.observations.length > BENCHMARK_MAX_OBSERVATIONS_PER_SNAPSHOT) throw new Error("source snapshot exceeds the observation limit");
|
|
192
|
+
const observations = value.observations.map(validateBenchmarkObservation);
|
|
193
|
+
if (observations.some((observation) => observation.provenance.sourceId !== sourceId || observation.provenance.retrievedAt !== retrievedAt)) {
|
|
194
|
+
throw new Error("source snapshot provenance mismatch");
|
|
195
|
+
}
|
|
196
|
+
return { sourceId, snapshotId, retrievedAt, observations };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export class BenchmarkCatalog implements BenchmarkController {
|
|
200
|
+
private readonly clock: () => number;
|
|
201
|
+
private readonly refreshIntervalMs: number;
|
|
202
|
+
private readonly states = new Map<string, BenchmarkSourceStatus>();
|
|
203
|
+
|
|
204
|
+
constructor(
|
|
205
|
+
private readonly store: BenchmarkStore,
|
|
206
|
+
private readonly sources: BenchmarkSource[],
|
|
207
|
+
options: BenchmarkCatalogOptions = {},
|
|
208
|
+
) {
|
|
209
|
+
this.clock = options.clock ?? Date.now;
|
|
210
|
+
this.refreshIntervalMs = options.refreshIntervalMs ?? BENCHMARK_REFRESH_INTERVAL_MS;
|
|
211
|
+
for (const source of sources) {
|
|
212
|
+
const evidence = store.latest(source.id);
|
|
213
|
+
this.states.set(source.id, {
|
|
214
|
+
id: source.id,
|
|
215
|
+
ok: null,
|
|
216
|
+
hasEvidence: evidence !== null,
|
|
217
|
+
lastAttemptAt: null,
|
|
218
|
+
lastSuccessAt: evidence?.retrievedAt ?? null,
|
|
219
|
+
observations: evidence?.observations.length ?? 0,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async refresh(force = false): Promise<BenchmarkRefreshResult> {
|
|
225
|
+
const now = this.clock();
|
|
226
|
+
await Promise.all(this.sources.map(async (source) => {
|
|
227
|
+
const prior = this.states.get(source.id)!;
|
|
228
|
+
if (!force && prior.lastAttemptAt !== null && now - prior.lastAttemptAt < this.refreshIntervalMs) return;
|
|
229
|
+
this.states.set(source.id, { ...prior, lastAttemptAt: now });
|
|
230
|
+
try {
|
|
231
|
+
const snapshot = validateSourceSnapshot(await source.fetch(), source.id);
|
|
232
|
+
const published = this.store.publish(snapshot.sourceId, snapshot.snapshotId, snapshot.observations);
|
|
233
|
+
this.states.set(source.id, { id: source.id, ok: true, hasEvidence: true, lastAttemptAt: now, lastSuccessAt: published.retrievedAt, observations: published.observations.length });
|
|
234
|
+
} catch {
|
|
235
|
+
const evidence = this.store.latest(source.id);
|
|
236
|
+
this.states.set(source.id, { id: source.id, ok: false, hasEvidence: evidence !== null, lastAttemptAt: now, lastSuccessAt: evidence?.retrievedAt ?? prior.lastSuccessAt, observations: evidence?.observations.length ?? prior.observations, error: "source refresh failed" });
|
|
237
|
+
}
|
|
238
|
+
}));
|
|
239
|
+
return { observedAt: now, sources: this.status().sources };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
status(): BenchmarkRefreshResult {
|
|
243
|
+
return { observedAt: this.clock(), sources: this.sources.map((source) => structuredClone(this.states.get(source.id)!)) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
query(input: BenchmarkQuery): BenchmarkQueryResult {
|
|
247
|
+
const sourceId = identityPart(input.sourceId, "source id");
|
|
248
|
+
const snapshot = this.store.latest(sourceId);
|
|
249
|
+
if (!snapshot) throw new Error("benchmark evidence is not available for the source");
|
|
250
|
+
const requestedLimit = Number.isFinite(input.limit) ? Math.floor(input.limit!) : BENCHMARK_DEFAULT_QUERY_LIMIT;
|
|
251
|
+
const limit = Math.max(1, Math.min(BENCHMARK_MAX_QUERY_LIMIT, requestedLimit));
|
|
252
|
+
const model = input.model?.trim().toLowerCase();
|
|
253
|
+
const dimension = input.dimension?.trim().toLowerCase();
|
|
254
|
+
const matched = snapshot.observations.filter((observation) => (!model || observation.model.canonical === model || observation.model.aliases.includes(model)) && (!dimension || observation.dimension === dimension));
|
|
255
|
+
const freshUntil = Math.min(...snapshot.observations.map((observation) => observation.provenance.freshUntil));
|
|
256
|
+
return {
|
|
257
|
+
...structuredClone(snapshot),
|
|
258
|
+
observations: structuredClone(matched.slice(0, limit)),
|
|
259
|
+
completeness: matched.length > limit ? "truncated" : "complete",
|
|
260
|
+
freshness: this.clock() <= freshUntil ? "fresh" : "stale",
|
|
261
|
+
freshUntil,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
COMPACTION_DURATION_ESTIMATE_MAX_SAMPLES,
|
|
3
|
+
COMPACTION_DURATION_ESTIMATE_MIN_SAMPLES,
|
|
2
4
|
CONTEXT_OBSERVATION_MAX_AGE_MS,
|
|
3
5
|
CONTEXT_OBSERVATION_MAX_CHARACTERS,
|
|
4
6
|
MILLISECONDS_PER_HOUR,
|
|
@@ -278,3 +280,32 @@ export function assessContextTelemetry(
|
|
|
278
280
|
},
|
|
279
281
|
};
|
|
280
282
|
}
|
|
283
|
+
|
|
284
|
+
export interface CompactionDurationEstimate {
|
|
285
|
+
ms: number | null;
|
|
286
|
+
confidence: "cold-start" | "learned";
|
|
287
|
+
sampleSize: number;
|
|
288
|
+
observedAt: number;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Learns a bounded duration estimate from the most recent completed Pi compactions so the drain
|
|
293
|
+
* animation can show an approximate time-to-completion instead of a fixed-rate guess. Reads only
|
|
294
|
+
* the numeric `compaction-duration` value already recorded content-free by CompactionTelemetry
|
|
295
|
+
* (never transcript content, credentials, or attributes) and is bounded to the caller-provided
|
|
296
|
+
* rows — callers must query with `limit: COMPACTION_DURATION_ESTIMATE_MAX_SAMPLES` so retention is
|
|
297
|
+
* bounded at the query layer, not just here. Below COMPACTION_DURATION_ESTIMATE_MIN_SAMPLES samples
|
|
298
|
+
* the estimate stays explicit cold-start uncertainty rather than a guess from too little evidence.
|
|
299
|
+
*/
|
|
300
|
+
export function estimateCompactionDuration(compactions: StoredMetricObservation[], now = Date.now()): CompactionDurationEstimate {
|
|
301
|
+
const durations = compactions
|
|
302
|
+
.filter((row) => row.source === "pi-context" && row.scope === "compaction" && row.metric === "compaction-duration")
|
|
303
|
+
.sort((left, right) => right.observedAt - left.observedAt || right.id - left.id)
|
|
304
|
+
.slice(0, COMPACTION_DURATION_ESTIMATE_MAX_SAMPLES)
|
|
305
|
+
.flatMap((row) => typeof row.value === "number" && Number.isFinite(row.value) && row.value >= 0 ? [row.value] : []);
|
|
306
|
+
if (durations.length < COMPACTION_DURATION_ESTIMATE_MIN_SAMPLES) {
|
|
307
|
+
return { ms: null, confidence: "cold-start", sampleSize: durations.length, observedAt: now };
|
|
308
|
+
}
|
|
309
|
+
const median = percentile(durations, 0.5);
|
|
310
|
+
return { ms: median === null ? null : Math.round(median), confidence: "learned", sampleSize: durations.length, observedAt: now };
|
|
311
|
+
}
|
package/src/domain/metric.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { METRIC_ATTRIBUTES_MAX_DEPTH, METRIC_ATTRIBUTES_MAX_SERIALIZED_CHARACTERS, METRIC_IDENTITY_MAX_CHARACTERS } from "../constants.ts";
|
|
2
|
+
|
|
3
|
+
export const METRIC_UNITS = ["ratio", "usd", "tokens", "tokens-per-second", "requests", "milliseconds", "count"] as const;
|
|
2
4
|
export type MetricUnit = typeof METRIC_UNITS[number];
|
|
3
5
|
|
|
4
6
|
export interface MetricObservation {
|
|
@@ -26,11 +28,52 @@ export interface MetricQuery {
|
|
|
26
28
|
order?: "asc" | "desc";
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
const SENSITIVE_ATTRIBUTE_KEYS = new Set([
|
|
32
|
+
"accesstoken",
|
|
33
|
+
"refreshtoken",
|
|
34
|
+
"authorization",
|
|
35
|
+
"apikey",
|
|
36
|
+
"secret",
|
|
37
|
+
"password",
|
|
38
|
+
"cookie",
|
|
39
|
+
"credential",
|
|
40
|
+
"otpseed",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function assertCredentialSafeAttributes(value: unknown, depth = 0): void {
|
|
44
|
+
if (depth > METRIC_ATTRIBUTES_MAX_DEPTH) throw new Error("attributes exceed the nesting depth limit");
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
for (const item of value) assertCredentialSafeAttributes(item, depth + 1);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (typeof value !== "object" || value === null) return;
|
|
50
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
51
|
+
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
52
|
+
if (SENSITIVE_ATTRIBUTE_KEYS.has(normalized)) throw new Error("attributes contain a sensitive field");
|
|
53
|
+
assertCredentialSafeAttributes(nested, depth + 1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateAttributes(value: unknown): Record<string, unknown> {
|
|
58
|
+
if (value === undefined) return {};
|
|
59
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("attributes must be an object");
|
|
60
|
+
let serialized: string;
|
|
61
|
+
try {
|
|
62
|
+
serialized = JSON.stringify(value);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error("attributes must be JSON serializable");
|
|
65
|
+
}
|
|
66
|
+
if (serialized.length > METRIC_ATTRIBUTES_MAX_SERIALIZED_CHARACTERS) throw new Error("attributes exceed the serialized size limit");
|
|
67
|
+
assertCredentialSafeAttributes(value);
|
|
68
|
+
return value as Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
|
|
29
71
|
export function validateMetricObservation(value: unknown): MetricObservation {
|
|
30
72
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("metric observation must be an object");
|
|
31
73
|
const input = value as Record<string, unknown>;
|
|
32
74
|
for (const key of ["source", "scope", "metric", "unit"] as const) {
|
|
33
75
|
if (typeof input[key] !== "string" || input[key].trim().length === 0) throw new Error(`${key} is required`);
|
|
76
|
+
if (input[key].length > METRIC_IDENTITY_MAX_CHARACTERS) throw new Error(`${key} exceeds the length limit`);
|
|
34
77
|
}
|
|
35
78
|
if (!METRIC_UNITS.includes(input["unit"] as MetricUnit)) throw new Error("unit is not supported");
|
|
36
79
|
if (input["value"] !== null && (typeof input["value"] !== "number" || !Number.isFinite(input["value"]))) {
|
|
@@ -39,9 +82,7 @@ export function validateMetricObservation(value: unknown): MetricObservation {
|
|
|
39
82
|
if (typeof input["observedAt"] !== "number" || !Number.isSafeInteger(input["observedAt"]) || input["observedAt"] < 0) {
|
|
40
83
|
throw new Error("observedAt must be a non-negative integer timestamp");
|
|
41
84
|
}
|
|
42
|
-
|
|
43
|
-
throw new Error("attributes must be an object");
|
|
44
|
-
}
|
|
85
|
+
const attributes = validateAttributes(input["attributes"]);
|
|
45
86
|
return {
|
|
46
87
|
source: input["source"] as string,
|
|
47
88
|
scope: input["scope"] as string,
|
|
@@ -49,6 +90,6 @@ export function validateMetricObservation(value: unknown): MetricObservation {
|
|
|
49
90
|
value: input["value"] as number | null,
|
|
50
91
|
unit: input["unit"] as MetricUnit,
|
|
51
92
|
observedAt: input["observedAt"] as number,
|
|
52
|
-
attributes
|
|
93
|
+
attributes,
|
|
53
94
|
};
|
|
54
95
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MODEL_AGGREGATE_MAX_GROUPS,
|
|
3
|
+
MODEL_AGGREGATE_MAX_ROWS,
|
|
4
|
+
MODEL_OBSERVATION_FRESH_MS,
|
|
5
|
+
MODEL_OBSERVATION_IDENTITY_MAX_CHARACTERS,
|
|
6
|
+
} from "../constants.ts";
|
|
7
|
+
import { normalizeModelIdentity } from "./benchmark.ts";
|
|
8
|
+
import type { MetricObservation, MetricUnit, StoredMetricObservation } from "./metric.ts";
|
|
9
|
+
|
|
10
|
+
export const TASK_CLASSES = ["coding", "research", "planning", "general"] as const;
|
|
11
|
+
export type ModelTaskClass = typeof TASK_CLASSES[number];
|
|
12
|
+
export type ExplicitOutcome = "accepted" | "rejected" | "unknown";
|
|
13
|
+
|
|
14
|
+
export interface ModelRunObservation {
|
|
15
|
+
runId: string;
|
|
16
|
+
provider: string;
|
|
17
|
+
model: string;
|
|
18
|
+
thinking: string;
|
|
19
|
+
taskClass: ModelTaskClass;
|
|
20
|
+
startedAt: number;
|
|
21
|
+
firstTokenAt: number | null;
|
|
22
|
+
completedAt: number;
|
|
23
|
+
inputTokens: number;
|
|
24
|
+
outputTokens: number;
|
|
25
|
+
cacheReadTokens: number;
|
|
26
|
+
cacheWriteTokens: number;
|
|
27
|
+
costUsd: number;
|
|
28
|
+
providerResponses: number;
|
|
29
|
+
toolCalls: number;
|
|
30
|
+
toolFailures: number;
|
|
31
|
+
stopReason: "stop" | "length" | "toolUse" | "error" | "aborted" | "unknown";
|
|
32
|
+
explicitOutcome: ExplicitOutcome;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ModelMetricAggregate {
|
|
36
|
+
provider: string;
|
|
37
|
+
model: string;
|
|
38
|
+
thinking: string;
|
|
39
|
+
taskClass: ModelTaskClass;
|
|
40
|
+
dimension: string;
|
|
41
|
+
unit: MetricUnit;
|
|
42
|
+
sampleSize: number;
|
|
43
|
+
median: number;
|
|
44
|
+
p90: number;
|
|
45
|
+
medianAbsoluteDeviation: number;
|
|
46
|
+
latestAt: number;
|
|
47
|
+
freshness: "fresh" | "stale";
|
|
48
|
+
confidence: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ModelAggregateOptions {
|
|
52
|
+
now?: number;
|
|
53
|
+
freshForMs?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ALLOWED_FIELDS = new Set<keyof ModelRunObservation>([
|
|
57
|
+
"runId", "provider", "model", "thinking", "taskClass", "startedAt", "firstTokenAt", "completedAt",
|
|
58
|
+
"inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd", "providerResponses",
|
|
59
|
+
"toolCalls", "toolFailures", "stopReason", "explicitOutcome",
|
|
60
|
+
]);
|
|
61
|
+
const STOP_REASONS = new Set<ModelRunObservation["stopReason"]>(["stop", "length", "toolUse", "error", "aborted", "unknown"]);
|
|
62
|
+
const OUTCOMES = new Set<ExplicitOutcome>(["accepted", "rejected", "unknown"]);
|
|
63
|
+
|
|
64
|
+
function text(value: unknown, name: string): string {
|
|
65
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MODEL_OBSERVATION_IDENTITY_MAX_CHARACTERS || /\p{Cc}/u.test(value)) throw new Error(`${name} is invalid`);
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function nonNegative(value: unknown, name: string, integer = false): number {
|
|
70
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || (integer && !Number.isSafeInteger(value))) throw new Error(`${name} is invalid`);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function validateModelRunObservation(value: unknown): ModelRunObservation {
|
|
75
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("model run observation must be an object");
|
|
76
|
+
const input = value as Record<string, unknown>;
|
|
77
|
+
for (const key of Object.keys(input)) if (!ALLOWED_FIELDS.has(key as keyof ModelRunObservation)) throw new Error(`unsupported field: ${key}`);
|
|
78
|
+
const identity = normalizeModelIdentity(text(input["provider"], "provider"), text(input["model"], "model"));
|
|
79
|
+
const startedAt = nonNegative(input["startedAt"], "start time", true);
|
|
80
|
+
const completedAt = nonNegative(input["completedAt"], "completion time", true);
|
|
81
|
+
const firstTokenAt = input["firstTokenAt"] === null ? null : nonNegative(input["firstTokenAt"], "first-token time", true);
|
|
82
|
+
if (completedAt < startedAt || (firstTokenAt !== null && (firstTokenAt < startedAt || firstTokenAt > completedAt))) throw new Error("model run timestamps are not ordered");
|
|
83
|
+
if (!TASK_CLASSES.includes(input["taskClass"] as ModelTaskClass)) throw new Error("task class is invalid");
|
|
84
|
+
if (!STOP_REASONS.has(input["stopReason"] as ModelRunObservation["stopReason"])) throw new Error("stop reason is invalid");
|
|
85
|
+
if (!OUTCOMES.has(input["explicitOutcome"] as ExplicitOutcome)) throw new Error("explicit outcome is invalid");
|
|
86
|
+
const providerResponses = nonNegative(input["providerResponses"], "provider response count", true);
|
|
87
|
+
const toolCalls = nonNegative(input["toolCalls"], "tool call count", true);
|
|
88
|
+
const toolFailures = nonNegative(input["toolFailures"], "tool failure count", true);
|
|
89
|
+
if (providerResponses < 1 || toolFailures > toolCalls) throw new Error("model run counters are inconsistent");
|
|
90
|
+
return {
|
|
91
|
+
runId: text(input["runId"], "run id"), provider: identity.provider, model: identity.model,
|
|
92
|
+
thinking: text(input["thinking"], "thinking level"), taskClass: input["taskClass"] as ModelTaskClass,
|
|
93
|
+
startedAt, firstTokenAt, completedAt,
|
|
94
|
+
inputTokens: nonNegative(input["inputTokens"], "input tokens"),
|
|
95
|
+
outputTokens: nonNegative(input["outputTokens"], "output tokens"),
|
|
96
|
+
cacheReadTokens: nonNegative(input["cacheReadTokens"], "cache read tokens"),
|
|
97
|
+
cacheWriteTokens: nonNegative(input["cacheWriteTokens"], "cache write tokens"),
|
|
98
|
+
costUsd: nonNegative(input["costUsd"], "cost"), providerResponses, toolCalls, toolFailures,
|
|
99
|
+
stopReason: input["stopReason"] as ModelRunObservation["stopReason"], explicitOutcome: input["explicitOutcome"] as ExplicitOutcome,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function classifyTaskFromTools(toolNames: string[]): ModelTaskClass {
|
|
104
|
+
const names = new Set(toolNames.slice(0, 100).map((name) => name.toLowerCase()));
|
|
105
|
+
if (["edit", "write", "read", "bash", "grep", "find", "ls"].some((name) => names.has(name))) return "coding";
|
|
106
|
+
if (["web_fetch", "web_search"].some((name) => names.has(name))) return "research";
|
|
107
|
+
if (["tasks", "papyrus_create", "papyrus_graph"].some((name) => names.has(name))) return "planning";
|
|
108
|
+
return "general";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function modelRunMetrics(value: ModelRunObservation): MetricObservation[] {
|
|
112
|
+
const run = validateModelRunObservation(value);
|
|
113
|
+
const scope = `${run.provider}/${run.model}`;
|
|
114
|
+
const attributes = { provider: run.provider, model: run.model, thinking: run.thinking, taskClass: run.taskClass, runId: run.runId };
|
|
115
|
+
const metric = (name: string, amount: number, unit: MetricUnit): MetricObservation => ({ source: "local-model", scope, metric: name, value: amount, unit, observedAt: run.completedAt, attributes });
|
|
116
|
+
const wallMs = run.completedAt - run.startedAt;
|
|
117
|
+
const totalInput = run.inputTokens + run.cacheReadTokens;
|
|
118
|
+
const metrics: MetricObservation[] = [];
|
|
119
|
+
if (run.firstTokenAt !== null) metrics.push(metric("ttft", run.firstTokenAt - run.startedAt, "milliseconds"));
|
|
120
|
+
metrics.push(
|
|
121
|
+
metric("wall-latency", wallMs, "milliseconds"),
|
|
122
|
+
metric("output-throughput", wallMs === 0 ? 0 : run.outputTokens / (wallMs / 1_000), "tokens-per-second"),
|
|
123
|
+
metric("input-tokens", run.inputTokens, "tokens"),
|
|
124
|
+
metric("output-tokens", run.outputTokens, "tokens"),
|
|
125
|
+
metric("cache-read-tokens", run.cacheReadTokens, "tokens"),
|
|
126
|
+
metric("cache-write-tokens", run.cacheWriteTokens, "tokens"),
|
|
127
|
+
metric("cache-read-ratio", totalInput === 0 ? 0 : run.cacheReadTokens / totalInput, "ratio"),
|
|
128
|
+
metric("cost", run.costUsd, "usd"),
|
|
129
|
+
metric("provider-responses", run.providerResponses, "count"),
|
|
130
|
+
metric("retry-count", Math.max(0, run.providerResponses - 1), "count"),
|
|
131
|
+
metric("tool-calls", run.toolCalls, "count"),
|
|
132
|
+
metric("tool-failures", run.toolFailures, "count"),
|
|
133
|
+
metric("failure", run.stopReason === "error" ? 1 : 0, "ratio"),
|
|
134
|
+
);
|
|
135
|
+
if (run.explicitOutcome !== "unknown") metrics.push(metric("outcome-accepted", run.explicitOutcome === "accepted" ? 1 : 0, "ratio"));
|
|
136
|
+
return metrics;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function percentile(sorted: number[], fraction: number): number {
|
|
140
|
+
return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]!;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function median(sorted: number[]): number {
|
|
144
|
+
const middle = Math.floor(sorted.length / 2);
|
|
145
|
+
return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function aggregateModelMetrics(input: StoredMetricObservation[], options: ModelAggregateOptions = {}): ModelMetricAggregate[] {
|
|
149
|
+
const now = options.now ?? Date.now();
|
|
150
|
+
const freshForMs = options.freshForMs ?? MODEL_OBSERVATION_FRESH_MS;
|
|
151
|
+
if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(freshForMs) || freshForMs <= 0) throw new Error("aggregate time bounds are invalid");
|
|
152
|
+
const groups = new Map<string, StoredMetricObservation[]>();
|
|
153
|
+
for (const row of input.slice(0, MODEL_AGGREGATE_MAX_ROWS)) {
|
|
154
|
+
if (row.source !== "local-model" || typeof row.value !== "number" || !Number.isFinite(row.value)) continue;
|
|
155
|
+
const provider = row.attributes["provider"];
|
|
156
|
+
const model = row.attributes["model"];
|
|
157
|
+
const thinking = row.attributes["thinking"];
|
|
158
|
+
const taskClass = row.attributes["taskClass"];
|
|
159
|
+
if (typeof provider !== "string" || typeof model !== "string" || typeof thinking !== "string" || !TASK_CLASSES.includes(taskClass as ModelTaskClass)) continue;
|
|
160
|
+
const key = JSON.stringify([provider, model, thinking, taskClass, row.metric, row.unit]);
|
|
161
|
+
if (!groups.has(key) && groups.size >= MODEL_AGGREGATE_MAX_GROUPS) continue;
|
|
162
|
+
const rows = groups.get(key) ?? [];
|
|
163
|
+
rows.push(row);
|
|
164
|
+
groups.set(key, rows);
|
|
165
|
+
}
|
|
166
|
+
return [...groups.entries()].map(([key, rows]) => {
|
|
167
|
+
const [provider, model, thinking, taskClass, dimension, unit] = JSON.parse(key) as [string, string, string, ModelTaskClass, string, MetricUnit];
|
|
168
|
+
const values = rows.map((row) => row.value as number).sort((left, right) => left - right);
|
|
169
|
+
const center = median(values);
|
|
170
|
+
const deviations = values.map((value) => Math.abs(value - center)).sort((left, right) => left - right);
|
|
171
|
+
const latestAt = Math.max(...rows.map((row) => row.observedAt));
|
|
172
|
+
const age = Math.max(0, now - latestAt);
|
|
173
|
+
const recency = Math.max(0, 1 - (age / freshForMs));
|
|
174
|
+
return {
|
|
175
|
+
provider, model, thinking, taskClass, dimension, unit,
|
|
176
|
+
sampleSize: values.length, median: center, p90: percentile(values, 0.9), medianAbsoluteDeviation: median(deviations), latestAt,
|
|
177
|
+
freshness: age <= freshForMs ? "fresh" as const : "stale" as const,
|
|
178
|
+
confidence: Math.min(1, Math.sqrt(values.length / 20)) * recency,
|
|
179
|
+
};
|
|
180
|
+
}).sort((left, right) => left.provider.localeCompare(right.provider) || left.model.localeCompare(right.model) || left.dimension.localeCompare(right.dimension));
|
|
181
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { MODEL_AGGREGATE_MAX_ROWS, MODEL_OBSERVATION_FRESH_MS, MODEL_RANKING_MAX_SOURCES } from "../constants.ts";
|
|
2
|
+
import type { BenchmarkStore } from "../ports/benchmark-store.ts";
|
|
3
|
+
import type { MetricStore } from "../ports/metric-store.ts";
|
|
4
|
+
import { aggregateModelMetrics } from "./model-observation.ts";
|
|
5
|
+
import { rankModelCandidates, type ModelCandidate, type ModelRankingResult, type ScopeAuthority, type UtilityWeights } from "./model-ranking.ts";
|
|
6
|
+
import type { ModelTaskClass } from "./model-observation.ts";
|
|
7
|
+
|
|
8
|
+
export interface ModelRecommendationInput {
|
|
9
|
+
candidates: ModelCandidate[];
|
|
10
|
+
scopeAuthority: ScopeAuthority;
|
|
11
|
+
taskClass: ModelTaskClass;
|
|
12
|
+
budgetPressure: number;
|
|
13
|
+
weights: UtilityWeights;
|
|
14
|
+
sourceIds: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ModelRanker {
|
|
18
|
+
rank(input: ModelRecommendationInput): ModelRankingResult;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class EvidenceModelRanker implements ModelRanker {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly benchmarks: BenchmarkStore,
|
|
24
|
+
private readonly metrics: MetricStore,
|
|
25
|
+
private readonly clock: () => number = Date.now,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
rank(input: ModelRecommendationInput): ModelRankingResult {
|
|
29
|
+
if (!Array.isArray(input.sourceIds) || input.sourceIds.length > MODEL_RANKING_MAX_SOURCES || !input.sourceIds.every((sourceId) => typeof sourceId === "string" && sourceId.length > 0 && sourceId.length <= 160)) {
|
|
30
|
+
throw new Error("benchmark source selection is invalid");
|
|
31
|
+
}
|
|
32
|
+
const sourceIds = [...new Set(input.sourceIds)];
|
|
33
|
+
const { sourceIds: _sourceIds, ...rankingInput } = input;
|
|
34
|
+
const externalEvidence = sourceIds.flatMap((sourceId) => this.benchmarks.latest(sourceId)?.observations ?? []);
|
|
35
|
+
const localRows = this.metrics.query({ source: "local-model", order: "desc", limit: MODEL_AGGREGATE_MAX_ROWS });
|
|
36
|
+
const now = this.clock();
|
|
37
|
+
const localEvidence = aggregateModelMetrics(localRows, { now, freshForMs: MODEL_OBSERVATION_FRESH_MS });
|
|
38
|
+
return rankModelCandidates({ ...rankingInput, externalEvidence, localEvidence, now });
|
|
39
|
+
}
|
|
40
|
+
}
|