@danypops/jittor 0.14.0 → 0.16.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/package.json +4 -3
- package/src/adapters/artificial-analysis-direct-source.ts +42 -11
- package/src/adapters/lmarena-hf-source.ts +37 -15
- package/src/adapters/metric-benchmark-store.ts +29 -18
- package/src/adapters/openrouter-benchmark-source.ts +75 -19
- package/src/adapters/openrouter-design-arena-source.ts +36 -19
- package/src/adapters/sqlite-metric-store.ts +48 -25
- package/src/adapters/sqlite-session-identity-store.ts +13 -7
- package/src/cli-commands/benchmarks.ts +87 -22
- package/src/cli-commands/compaction.ts +7 -2
- package/src/cli-commands/context.ts +10 -2
- package/src/cli-commands/metrics.ts +128 -31
- package/src/cli-commands/op.ts +6 -1
- package/src/cli-commands/route-args.ts +5 -1
- package/src/cli-commands/router.ts +61 -18
- package/src/cli-commands/service-daemon.ts +31 -13
- package/src/cli-commands/session.ts +15 -4
- package/src/cli-commands/support.ts +1 -1
- package/src/cli.ts +30 -23
- package/src/client.ts +1 -1
- package/src/constants.ts +1 -1
- package/src/daemon.ts +44 -24
- package/src/domain/benchmark.ts +72 -40
- package/src/domain/codex-recovery.ts +34 -25
- package/src/domain/context-hub.ts +35 -26
- package/src/domain/context-telemetry.ts +106 -35
- package/src/domain/metric.ts +11 -11
- package/src/domain/model-observation.ts +139 -55
- package/src/domain/model-ranking-service.ts +13 -3
- package/src/domain/model-ranking.ts +126 -60
- package/src/domain/task-cost.ts +52 -11
- package/src/domain/task-focus.ts +11 -8
- package/src/domain/usage.ts +2 -2
- package/src/index.ts +69 -69
- package/src/log.ts +7 -2
- package/src/operations/benchmark-operations.ts +1 -1
- package/src/operations/context-operations.ts +15 -6
- package/src/operations/metrics-operations.ts +63 -28
- package/src/operations/model-ranking-operations.ts +9 -2
- package/src/operations/router-operations.ts +8 -4
- package/src/operations/session-identity-operations.ts +1 -1
- package/src/operations/session-scope.ts +5 -3
- package/src/policy.ts +22 -17
- package/src/ports/benchmark-controller.ts +1 -5
- package/src/ports/metric-store.ts +1 -1
- package/src/providers/anthropic-contracts.ts +13 -3
- package/src/providers/codex-contracts.ts +60 -52
- package/src/providers/codex.ts +16 -19
- package/src/providers/google-vertex-budget-contracts.ts +24 -14
- package/src/providers/google-vertex-budget.ts +15 -13
- package/src/providers/google-vertex-contracts.ts +36 -24
- package/src/providers/openrouter-contracts.ts +49 -51
- package/src/providers/openrouter.ts +21 -15
- package/src/providers/telemetry-sources.ts +13 -10
- package/src/router.ts +92 -43
- package/src/service.ts +93 -32
- package/src/session-identity-service.ts +10 -2
- package/src/state.ts +4 -10
- package/src/vehicle-registration.ts +154 -0
|
@@ -8,7 +8,10 @@ import {
|
|
|
8
8
|
} from "../constants.ts";
|
|
9
9
|
import type { MetricObservation, StoredMetricObservation } from "./metric.ts";
|
|
10
10
|
|
|
11
|
-
interface PayloadSize {
|
|
11
|
+
interface PayloadSize {
|
|
12
|
+
characters: number;
|
|
13
|
+
bytes: number;
|
|
14
|
+
}
|
|
12
15
|
|
|
13
16
|
export interface PapyrusContextInjection {
|
|
14
17
|
schema: typeof PAPYRUS_CONTEXT_INJECTION_SCHEMA;
|
|
@@ -26,7 +29,21 @@ export interface PapyrusContextInjection {
|
|
|
26
29
|
unchanged: boolean;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
const TOP_LEVEL_FIELDS = new Set([
|
|
32
|
+
const TOP_LEVEL_FIELDS = new Set([
|
|
33
|
+
"schema",
|
|
34
|
+
"observedAt",
|
|
35
|
+
"sequence",
|
|
36
|
+
"producerId",
|
|
37
|
+
"before",
|
|
38
|
+
"rules",
|
|
39
|
+
"tasks",
|
|
40
|
+
"injected",
|
|
41
|
+
"after",
|
|
42
|
+
"estimatedTokens",
|
|
43
|
+
"share",
|
|
44
|
+
"fingerprint",
|
|
45
|
+
"unchanged",
|
|
46
|
+
]);
|
|
30
47
|
const SIZE_FIELDS = new Set(["characters", "bytes"]);
|
|
31
48
|
const RULE_SIZE_FIELDS = new Set(["characters", "bytes", "count"]);
|
|
32
49
|
|
|
@@ -38,44 +55,46 @@ function record(value: unknown, name: string, fields: Set<string>): Record<strin
|
|
|
38
55
|
}
|
|
39
56
|
|
|
40
57
|
function integer(value: unknown, name: string, maximum = Number.MAX_SAFE_INTEGER): number {
|
|
41
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum)
|
|
58
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum)
|
|
59
|
+
throw new Error(`${name} must be a bounded non-negative integer`);
|
|
42
60
|
return value;
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
function size(value: unknown, name: string, fields = SIZE_FIELDS): PayloadSize {
|
|
46
64
|
const input = record(value, name, fields);
|
|
47
65
|
return {
|
|
48
|
-
characters: integer(input
|
|
49
|
-
bytes: integer(input
|
|
66
|
+
characters: integer(input.characters, `${name}.characters`, CONTEXT_OBSERVATION_MAX_CHARACTERS),
|
|
67
|
+
bytes: integer(input.bytes, `${name}.bytes`, CONTEXT_OBSERVATION_MAX_CHARACTERS * 4),
|
|
50
68
|
};
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
export function validatePapyrusContextInjection(value: unknown, now = Date.now()): PapyrusContextInjection {
|
|
54
72
|
const input = record(value, "context injection", TOP_LEVEL_FIELDS);
|
|
55
|
-
if (input
|
|
56
|
-
const observedAt = integer(input
|
|
73
|
+
if (input.schema !== PAPYRUS_CONTEXT_INJECTION_SCHEMA) throw new Error("context injection schema is not supported");
|
|
74
|
+
const observedAt = integer(input.observedAt, "observedAt");
|
|
57
75
|
if (Math.abs(now - observedAt) > CONTEXT_OBSERVATION_MAX_AGE_MS) throw new Error("context injection observation is stale");
|
|
58
|
-
const producerId = input
|
|
59
|
-
if (typeof producerId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(producerId))
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
76
|
+
const producerId = input.producerId;
|
|
77
|
+
if (typeof producerId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(producerId))
|
|
78
|
+
throw new Error("producerId must be a UUID");
|
|
79
|
+
const before = size(input.before, "before");
|
|
80
|
+
const rulesInput = record(input.rules, "rules", RULE_SIZE_FIELDS);
|
|
81
|
+
const rules = { ...size(rulesInput, "rules", RULE_SIZE_FIELDS), count: integer(rulesInput.count, "rules.count") };
|
|
82
|
+
const tasks = size(input.tasks, "tasks");
|
|
83
|
+
const injected = size(input.injected, "injected");
|
|
84
|
+
const after = size(input.after, "after");
|
|
66
85
|
if (injected.characters !== rules.characters + tasks.characters || after.characters !== before.characters + injected.characters) {
|
|
67
86
|
throw new Error("context injection sizes are inconsistent");
|
|
68
87
|
}
|
|
69
|
-
const estimatedTokens = integer(input
|
|
70
|
-
const share = input
|
|
88
|
+
const estimatedTokens = integer(input.estimatedTokens, "estimatedTokens", CONTEXT_OBSERVATION_MAX_CHARACTERS);
|
|
89
|
+
const share = input.share;
|
|
71
90
|
if (typeof share !== "number" || !Number.isFinite(share) || share < 0 || share > 1) throw new Error("share must be a ratio");
|
|
72
|
-
const fingerprint = input
|
|
91
|
+
const fingerprint = input.fingerprint;
|
|
73
92
|
if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint)) throw new Error("fingerprint must be a SHA-256 hex digest");
|
|
74
|
-
if (typeof input
|
|
93
|
+
if (typeof input.unchanged !== "boolean") throw new Error("unchanged must be boolean");
|
|
75
94
|
return {
|
|
76
95
|
schema: PAPYRUS_CONTEXT_INJECTION_SCHEMA,
|
|
77
96
|
observedAt,
|
|
78
|
-
sequence: integer(input
|
|
97
|
+
sequence: integer(input.sequence, "sequence"),
|
|
79
98
|
producerId,
|
|
80
99
|
before,
|
|
81
100
|
rules,
|
|
@@ -85,7 +104,7 @@ export function validatePapyrusContextInjection(value: unknown, now = Date.now()
|
|
|
85
104
|
estimatedTokens,
|
|
86
105
|
share,
|
|
87
106
|
fingerprint,
|
|
88
|
-
unchanged: input
|
|
107
|
+
unchanged: input.unchanged,
|
|
89
108
|
};
|
|
90
109
|
}
|
|
91
110
|
|
|
@@ -121,7 +140,9 @@ export interface CompactionStart {
|
|
|
121
140
|
contextTokens?: number;
|
|
122
141
|
}
|
|
123
142
|
|
|
124
|
-
interface OpenCompaction extends CompactionStart {
|
|
143
|
+
interface OpenCompaction extends CompactionStart {
|
|
144
|
+
startedAt: number;
|
|
145
|
+
}
|
|
125
146
|
interface UsageCounters {
|
|
126
147
|
turns: number;
|
|
127
148
|
injectedCharacters: number;
|
|
@@ -140,8 +161,12 @@ export class CompactionTelemetry {
|
|
|
140
161
|
private counters = emptyCounters();
|
|
141
162
|
private previousCompletedAt: number | undefined;
|
|
142
163
|
|
|
143
|
-
hasOpenCompaction(): boolean {
|
|
144
|
-
|
|
164
|
+
hasOpenCompaction(): boolean {
|
|
165
|
+
return this.open !== undefined;
|
|
166
|
+
}
|
|
167
|
+
observeTurn(): void {
|
|
168
|
+
this.counters.turns += 1;
|
|
169
|
+
}
|
|
145
170
|
observeInjection(characters: number, estimatedTokens: number): void {
|
|
146
171
|
this.counters.injectedCharacters += Math.max(0, characters);
|
|
147
172
|
this.counters.estimatedInjectedTokens += Math.max(0, estimatedTokens);
|
|
@@ -154,26 +179,68 @@ export class CompactionTelemetry {
|
|
|
154
179
|
|
|
155
180
|
begin(input: CompactionStart, now = Date.now()): MetricObservation {
|
|
156
181
|
this.open = { ...input, startedAt: now };
|
|
157
|
-
return {
|
|
182
|
+
return {
|
|
183
|
+
source: "pi-context",
|
|
184
|
+
scope: "compaction",
|
|
185
|
+
metric: "compaction-started",
|
|
186
|
+
value: 1,
|
|
187
|
+
unit: "count",
|
|
188
|
+
observedAt: now,
|
|
189
|
+
attributes: { ...input },
|
|
190
|
+
};
|
|
158
191
|
}
|
|
159
192
|
|
|
160
193
|
complete(input: Pick<CompactionStart, "reason" | "willRetry">, now = Date.now()): MetricObservation {
|
|
161
|
-
if (!this.open)
|
|
194
|
+
if (!this.open)
|
|
195
|
+
return {
|
|
196
|
+
source: "pi-context",
|
|
197
|
+
scope: "compaction",
|
|
198
|
+
metric: "compaction-unmatched",
|
|
199
|
+
value: 1,
|
|
200
|
+
unit: "count",
|
|
201
|
+
observedAt: now,
|
|
202
|
+
attributes: { ...input },
|
|
203
|
+
};
|
|
162
204
|
const open = this.open;
|
|
163
205
|
this.open = undefined;
|
|
164
206
|
const attributes = this.intervalAttributes(open, now);
|
|
165
207
|
this.previousCompletedAt = now;
|
|
166
208
|
this.counters = emptyCounters();
|
|
167
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
source: "pi-context",
|
|
211
|
+
scope: "compaction",
|
|
212
|
+
metric: "compaction-duration",
|
|
213
|
+
value: Math.max(0, now - open.startedAt),
|
|
214
|
+
unit: "milliseconds",
|
|
215
|
+
observedAt: now,
|
|
216
|
+
attributes: { ...attributes, reason: input.reason, willRetry: input.willRetry },
|
|
217
|
+
};
|
|
168
218
|
}
|
|
169
219
|
|
|
170
220
|
abort(now = Date.now(), abortReason = "aborted"): MetricObservation {
|
|
171
221
|
const open = this.open;
|
|
172
222
|
this.open = undefined;
|
|
173
|
-
if (!open)
|
|
223
|
+
if (!open)
|
|
224
|
+
return {
|
|
225
|
+
source: "pi-context",
|
|
226
|
+
scope: "compaction",
|
|
227
|
+
metric: "compaction-unmatched",
|
|
228
|
+
value: 1,
|
|
229
|
+
unit: "count",
|
|
230
|
+
observedAt: now,
|
|
231
|
+
attributes: { abortReason },
|
|
232
|
+
};
|
|
174
233
|
const attributes = this.intervalAttributes(open, now);
|
|
175
234
|
this.counters = emptyCounters();
|
|
176
|
-
return {
|
|
235
|
+
return {
|
|
236
|
+
source: "pi-context",
|
|
237
|
+
scope: "compaction",
|
|
238
|
+
metric: "compaction-aborted",
|
|
239
|
+
value: 1,
|
|
240
|
+
unit: "count",
|
|
241
|
+
observedAt: now,
|
|
242
|
+
attributes: { ...attributes, reason: open.reason, abortReason, durationMs: Math.max(0, now - open.startedAt) },
|
|
243
|
+
};
|
|
177
244
|
}
|
|
178
245
|
|
|
179
246
|
private intervalAttributes(open: OpenCompaction, now: number): Record<string, unknown> {
|
|
@@ -226,8 +293,12 @@ function numericAttribute(row: StoredMetricObservation, key: string): number | n
|
|
|
226
293
|
const value = row.attributes[key];
|
|
227
294
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
228
295
|
}
|
|
229
|
-
function average(values: number[]): number | null {
|
|
230
|
-
|
|
296
|
+
function average(values: number[]): number | null {
|
|
297
|
+
return values.length === 0 ? null : values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
298
|
+
}
|
|
299
|
+
function sum(values: number[]): number {
|
|
300
|
+
return values.reduce((total, value) => total + value, 0);
|
|
301
|
+
}
|
|
231
302
|
function percentile(values: number[], percentage: number): number | null {
|
|
232
303
|
if (values.length === 0) return null;
|
|
233
304
|
const sorted = [...values].sort((left, right) => left - right);
|
|
@@ -239,14 +310,14 @@ export function assessContextTelemetry(
|
|
|
239
310
|
compactions: StoredMetricObservation[],
|
|
240
311
|
options: { since: number; until: number; truncated: boolean },
|
|
241
312
|
): ContextAssessment {
|
|
242
|
-
const injectionValues = injections.flatMap((row) => typeof row.value === "number" ? [row.value] : []);
|
|
313
|
+
const injectionValues = injections.flatMap((row) => (typeof row.value === "number" ? [row.value] : []));
|
|
243
314
|
const shares = injections.flatMap((row) => numericAttribute(row, "share") ?? []);
|
|
244
|
-
const unchanged = injections.filter((row) => row.attributes
|
|
315
|
+
const unchanged = injections.filter((row) => row.attributes.unchanged === true).length;
|
|
245
316
|
const completed = compactions.filter((row) => row.metric === "compaction-duration" && typeof row.value === "number");
|
|
246
317
|
const aborted = compactions.filter((row) => row.metric === "compaction-aborted");
|
|
247
318
|
const reasons = { manual: 0, threshold: 0, overflow: 0 };
|
|
248
319
|
for (const row of completed) {
|
|
249
|
-
const reason = row.attributes
|
|
320
|
+
const reason = row.attributes.reason;
|
|
250
321
|
if (reason === "manual" || reason === "threshold" || reason === "overflow") reasons[reason] += 1;
|
|
251
322
|
}
|
|
252
323
|
const windowMs = Math.max(0, options.until - options.since);
|
|
@@ -302,7 +373,7 @@ export function estimateCompactionDuration(compactions: StoredMetricObservation[
|
|
|
302
373
|
.filter((row) => row.source === "pi-context" && row.scope === "compaction" && row.metric === "compaction-duration")
|
|
303
374
|
.sort((left, right) => right.observedAt - left.observedAt || right.id - left.id)
|
|
304
375
|
.slice(0, COMPACTION_DURATION_ESTIMATE_MAX_SAMPLES)
|
|
305
|
-
.flatMap((row) => typeof row.value === "number" && Number.isFinite(row.value) && row.value >= 0 ? [row.value] : []);
|
|
376
|
+
.flatMap((row) => (typeof row.value === "number" && Number.isFinite(row.value) && row.value >= 0 ? [row.value] : []));
|
|
306
377
|
if (durations.length < COMPACTION_DURATION_ESTIMATE_MIN_SAMPLES) {
|
|
307
378
|
return { ms: null, confidence: "cold-start", sampleSize: durations.length, observedAt: now };
|
|
308
379
|
}
|
package/src/domain/metric.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { METRIC_ATTRIBUTES_MAX_DEPTH, METRIC_ATTRIBUTES_MAX_SERIALIZED_CHARACTERS, METRIC_IDENTITY_MAX_CHARACTERS } from "../constants.ts";
|
|
2
2
|
|
|
3
3
|
export const METRIC_UNITS = ["ratio", "usd", "tokens", "tokens-per-second", "requests", "milliseconds", "count", "elo"] as const;
|
|
4
|
-
export type MetricUnit = typeof METRIC_UNITS[number];
|
|
4
|
+
export type MetricUnit = (typeof METRIC_UNITS)[number];
|
|
5
5
|
|
|
6
6
|
export interface MetricObservation {
|
|
7
7
|
source: string;
|
|
@@ -75,21 +75,21 @@ export function validateMetricObservation(value: unknown): MetricObservation {
|
|
|
75
75
|
if (typeof input[key] !== "string" || input[key].trim().length === 0) throw new Error(`${key} is required`);
|
|
76
76
|
if (input[key].length > METRIC_IDENTITY_MAX_CHARACTERS) throw new Error(`${key} exceeds the length limit`);
|
|
77
77
|
}
|
|
78
|
-
if (!METRIC_UNITS.includes(input
|
|
79
|
-
if (input
|
|
78
|
+
if (!METRIC_UNITS.includes(input.unit as MetricUnit)) throw new Error("unit is not supported");
|
|
79
|
+
if (input.value !== null && (typeof input.value !== "number" || !Number.isFinite(input.value))) {
|
|
80
80
|
throw new Error("value must be finite or null");
|
|
81
81
|
}
|
|
82
|
-
if (typeof input
|
|
82
|
+
if (typeof input.observedAt !== "number" || !Number.isSafeInteger(input.observedAt) || input.observedAt < 0) {
|
|
83
83
|
throw new Error("observedAt must be a non-negative integer timestamp");
|
|
84
84
|
}
|
|
85
|
-
const attributes = validateAttributes(input
|
|
85
|
+
const attributes = validateAttributes(input.attributes);
|
|
86
86
|
return {
|
|
87
|
-
source: input
|
|
88
|
-
scope: input
|
|
89
|
-
metric: input
|
|
90
|
-
value: input
|
|
91
|
-
unit: input
|
|
92
|
-
observedAt: input
|
|
87
|
+
source: input.source as string,
|
|
88
|
+
scope: input.scope as string,
|
|
89
|
+
metric: input.metric as string,
|
|
90
|
+
value: input.value as number | null,
|
|
91
|
+
unit: input.unit as MetricUnit,
|
|
92
|
+
observedAt: input.observedAt as number,
|
|
93
93
|
attributes,
|
|
94
94
|
};
|
|
95
95
|
}
|
|
@@ -17,9 +17,9 @@ import type { MetricObservation, MetricUnit, StoredMetricObservation } from "./m
|
|
|
17
17
|
* research on type simultaneously (e.g. reading a file, then searching the web in one turn).
|
|
18
18
|
*/
|
|
19
19
|
export const TASK_DOMAINS = ["coding", "design", "math", "general"] as const;
|
|
20
|
-
export type ModelTaskDomain = typeof TASK_DOMAINS[number];
|
|
20
|
+
export type ModelTaskDomain = (typeof TASK_DOMAINS)[number];
|
|
21
21
|
export const TASK_TYPES = ["research", "planning", "general"] as const;
|
|
22
|
-
export type ModelTaskType = typeof TASK_TYPES[number];
|
|
22
|
+
export type ModelTaskType = (typeof TASK_TYPES)[number];
|
|
23
23
|
export type ExplicitOutcome = "accepted" | "rejected" | "unknown";
|
|
24
24
|
|
|
25
25
|
export interface ModelRunObservation {
|
|
@@ -67,50 +67,80 @@ export interface ModelAggregateOptions {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
const ALLOWED_FIELDS = new Set<keyof ModelRunObservation>([
|
|
70
|
-
"runId",
|
|
71
|
-
"
|
|
72
|
-
"
|
|
70
|
+
"runId",
|
|
71
|
+
"provider",
|
|
72
|
+
"model",
|
|
73
|
+
"thinking",
|
|
74
|
+
"domain",
|
|
75
|
+
"type",
|
|
76
|
+
"startedAt",
|
|
77
|
+
"firstTokenAt",
|
|
78
|
+
"completedAt",
|
|
79
|
+
"inputTokens",
|
|
80
|
+
"outputTokens",
|
|
81
|
+
"cacheReadTokens",
|
|
82
|
+
"cacheWriteTokens",
|
|
83
|
+
"costUsd",
|
|
84
|
+
"providerResponses",
|
|
85
|
+
"toolCalls",
|
|
86
|
+
"toolFailures",
|
|
87
|
+
"stopReason",
|
|
88
|
+
"explicitOutcome",
|
|
73
89
|
]);
|
|
74
90
|
const STOP_REASONS = new Set<ModelRunObservation["stopReason"]>(["stop", "length", "toolUse", "error", "aborted", "unknown"]);
|
|
75
91
|
const OUTCOMES = new Set<ExplicitOutcome>(["accepted", "rejected", "unknown"]);
|
|
76
92
|
|
|
77
93
|
function text(value: unknown, name: string): string {
|
|
78
|
-
if (typeof value !== "string" || value.length === 0 || value.length > MODEL_OBSERVATION_IDENTITY_MAX_CHARACTERS || /\p{Cc}/u.test(value))
|
|
94
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MODEL_OBSERVATION_IDENTITY_MAX_CHARACTERS || /\p{Cc}/u.test(value))
|
|
95
|
+
throw new Error(`${name} is invalid`);
|
|
79
96
|
return value;
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
function nonNegative(value: unknown, name: string, integer = false): number {
|
|
83
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || (integer && !Number.isSafeInteger(value)))
|
|
100
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || (integer && !Number.isSafeInteger(value)))
|
|
101
|
+
throw new Error(`${name} is invalid`);
|
|
84
102
|
return value;
|
|
85
103
|
}
|
|
86
104
|
|
|
87
105
|
export function validateModelRunObservation(value: unknown): ModelRunObservation {
|
|
88
106
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("model run observation must be an object");
|
|
89
107
|
const input = value as Record<string, unknown>;
|
|
90
|
-
for (const key of Object.keys(input))
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
if (!
|
|
99
|
-
if (!
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
108
|
+
for (const key of Object.keys(input))
|
|
109
|
+
if (!ALLOWED_FIELDS.has(key as keyof ModelRunObservation)) throw new Error(`unsupported field: ${key}`);
|
|
110
|
+
const identity = normalizeModelIdentity(text(input.provider, "provider"), text(input.model, "model"));
|
|
111
|
+
const startedAt = nonNegative(input.startedAt, "start time", true);
|
|
112
|
+
const completedAt = nonNegative(input.completedAt, "completion time", true);
|
|
113
|
+
const firstTokenAt = input.firstTokenAt === null ? null : nonNegative(input.firstTokenAt, "first-token time", true);
|
|
114
|
+
if (completedAt < startedAt || (firstTokenAt !== null && (firstTokenAt < startedAt || firstTokenAt > completedAt)))
|
|
115
|
+
throw new Error("model run timestamps are not ordered");
|
|
116
|
+
if (!TASK_DOMAINS.includes(input.domain as ModelTaskDomain)) throw new Error("task domain is invalid");
|
|
117
|
+
if (!TASK_TYPES.includes(input.type as ModelTaskType)) throw new Error("task type is invalid");
|
|
118
|
+
if (!STOP_REASONS.has(input.stopReason as ModelRunObservation["stopReason"])) throw new Error("stop reason is invalid");
|
|
119
|
+
if (!OUTCOMES.has(input.explicitOutcome as ExplicitOutcome)) throw new Error("explicit outcome is invalid");
|
|
120
|
+
const providerResponses = nonNegative(input.providerResponses, "provider response count", true);
|
|
121
|
+
const toolCalls = nonNegative(input.toolCalls, "tool call count", true);
|
|
122
|
+
const toolFailures = nonNegative(input.toolFailures, "tool failure count", true);
|
|
103
123
|
if (providerResponses < 1 || toolFailures > toolCalls) throw new Error("model run counters are inconsistent");
|
|
104
124
|
return {
|
|
105
|
-
runId: text(input
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
125
|
+
runId: text(input.runId, "run id"),
|
|
126
|
+
provider: identity.provider,
|
|
127
|
+
model: identity.model,
|
|
128
|
+
thinking: text(input.thinking, "thinking level"),
|
|
129
|
+
domain: input.domain as ModelTaskDomain,
|
|
130
|
+
type: input.type as ModelTaskType,
|
|
131
|
+
startedAt,
|
|
132
|
+
firstTokenAt,
|
|
133
|
+
completedAt,
|
|
134
|
+
inputTokens: nonNegative(input.inputTokens, "input tokens"),
|
|
135
|
+
outputTokens: nonNegative(input.outputTokens, "output tokens"),
|
|
136
|
+
cacheReadTokens: nonNegative(input.cacheReadTokens, "cache read tokens"),
|
|
137
|
+
cacheWriteTokens: nonNegative(input.cacheWriteTokens, "cache write tokens"),
|
|
138
|
+
costUsd: nonNegative(input.costUsd, "cost"),
|
|
139
|
+
providerResponses,
|
|
140
|
+
toolCalls,
|
|
141
|
+
toolFailures,
|
|
142
|
+
stopReason: input.stopReason as ModelRunObservation["stopReason"],
|
|
143
|
+
explicitOutcome: input.explicitOutcome as ExplicitOutcome,
|
|
114
144
|
};
|
|
115
145
|
}
|
|
116
146
|
|
|
@@ -122,18 +152,37 @@ export interface ModelTaskClassification {
|
|
|
122
152
|
/** Domain and type are independent: a run can be domain=coding and type=research at once (e.g. reading a file, then searching the web in the same turn). */
|
|
123
153
|
export function classifyTaskFromTools(toolNames: string[]): ModelTaskClassification {
|
|
124
154
|
const names = new Set(toolNames.slice(0, 100).map((name) => name.toLowerCase()));
|
|
125
|
-
const domain: ModelTaskDomain = ["edit", "write", "read", "bash", "grep", "find", "ls"].some((name) => names.has(name))
|
|
155
|
+
const domain: ModelTaskDomain = ["edit", "write", "read", "bash", "grep", "find", "ls"].some((name) => names.has(name))
|
|
156
|
+
? "coding"
|
|
157
|
+
: "general";
|
|
126
158
|
const type: ModelTaskType = ["web_fetch", "web_search"].some((name) => names.has(name))
|
|
127
159
|
? "research"
|
|
128
|
-
: ["tasks", "papyrus_create", "papyrus_graph"].some((name) => names.has(name))
|
|
160
|
+
: ["tasks", "papyrus_create", "papyrus_graph"].some((name) => names.has(name))
|
|
161
|
+
? "planning"
|
|
162
|
+
: "general";
|
|
129
163
|
return { domain, type };
|
|
130
164
|
}
|
|
131
165
|
|
|
132
166
|
export function modelRunMetrics(value: ModelRunObservation): MetricObservation[] {
|
|
133
167
|
const run = validateModelRunObservation(value);
|
|
134
168
|
const scope = `${run.provider}/${run.model}`;
|
|
135
|
-
const attributes = {
|
|
136
|
-
|
|
169
|
+
const attributes = {
|
|
170
|
+
provider: run.provider,
|
|
171
|
+
model: run.model,
|
|
172
|
+
thinking: run.thinking,
|
|
173
|
+
domain: run.domain,
|
|
174
|
+
type: run.type,
|
|
175
|
+
runId: run.runId,
|
|
176
|
+
};
|
|
177
|
+
const metric = (name: string, amount: number, unit: MetricUnit): MetricObservation => ({
|
|
178
|
+
source: "local-model",
|
|
179
|
+
scope,
|
|
180
|
+
metric: name,
|
|
181
|
+
value: amount,
|
|
182
|
+
unit,
|
|
183
|
+
observedAt: run.completedAt,
|
|
184
|
+
attributes,
|
|
185
|
+
});
|
|
137
186
|
const wallMs = run.completedAt - run.startedAt;
|
|
138
187
|
const totalInput = run.inputTokens + run.cacheReadTokens;
|
|
139
188
|
const metrics: MetricObservation[] = [];
|
|
@@ -169,35 +218,70 @@ function median(sorted: number[]): number {
|
|
|
169
218
|
export function aggregateModelMetrics(input: StoredMetricObservation[], options: ModelAggregateOptions = {}): ModelMetricAggregate[] {
|
|
170
219
|
const now = options.now ?? Date.now();
|
|
171
220
|
const freshForMs = options.freshForMs ?? MODEL_OBSERVATION_FRESH_MS;
|
|
172
|
-
if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(freshForMs) || freshForMs <= 0)
|
|
221
|
+
if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(freshForMs) || freshForMs <= 0)
|
|
222
|
+
throw new Error("aggregate time bounds are invalid");
|
|
173
223
|
const groups = new Map<string, StoredMetricObservation[]>();
|
|
174
224
|
for (const row of input.slice(0, MODEL_AGGREGATE_MAX_ROWS)) {
|
|
175
225
|
if (row.source !== "local-model" || typeof row.value !== "number" || !Number.isFinite(row.value)) continue;
|
|
176
|
-
const provider = row.attributes
|
|
177
|
-
const model = row.attributes
|
|
178
|
-
const thinking = row.attributes
|
|
179
|
-
const domain = row.attributes
|
|
180
|
-
const type = row.attributes
|
|
181
|
-
if (
|
|
226
|
+
const provider = row.attributes.provider;
|
|
227
|
+
const model = row.attributes.model;
|
|
228
|
+
const thinking = row.attributes.thinking;
|
|
229
|
+
const domain = row.attributes.domain;
|
|
230
|
+
const type = row.attributes.type;
|
|
231
|
+
if (
|
|
232
|
+
typeof provider !== "string" ||
|
|
233
|
+
typeof model !== "string" ||
|
|
234
|
+
typeof thinking !== "string" ||
|
|
235
|
+
!TASK_DOMAINS.includes(domain as ModelTaskDomain) ||
|
|
236
|
+
!TASK_TYPES.includes(type as ModelTaskType)
|
|
237
|
+
)
|
|
238
|
+
continue;
|
|
182
239
|
const key = JSON.stringify([provider, model, thinking, domain, type, row.metric, row.unit]);
|
|
183
240
|
if (!groups.has(key) && groups.size >= MODEL_AGGREGATE_MAX_GROUPS) continue;
|
|
184
241
|
const rows = groups.get(key) ?? [];
|
|
185
242
|
rows.push(row);
|
|
186
243
|
groups.set(key, rows);
|
|
187
244
|
}
|
|
188
|
-
return [...groups.entries()]
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
245
|
+
return [...groups.entries()]
|
|
246
|
+
.map(([key, rows]) => {
|
|
247
|
+
const [provider, model, thinking, domain, type, dimension, unit] = JSON.parse(key) as [
|
|
248
|
+
string,
|
|
249
|
+
string,
|
|
250
|
+
string,
|
|
251
|
+
ModelTaskDomain,
|
|
252
|
+
ModelTaskType,
|
|
253
|
+
string,
|
|
254
|
+
MetricUnit,
|
|
255
|
+
];
|
|
256
|
+
const values = rows.map((row) => row.value as number).sort((left, right) => left - right);
|
|
257
|
+
const center = median(values);
|
|
258
|
+
const deviations = values.map((value) => Math.abs(value - center)).sort((left, right) => left - right);
|
|
259
|
+
const latestAt = Math.max(...rows.map((row) => row.observedAt));
|
|
260
|
+
const age = Math.max(0, now - latestAt);
|
|
261
|
+
const recency = Math.max(0, 1 - age / freshForMs);
|
|
262
|
+
return {
|
|
263
|
+
provider,
|
|
264
|
+
model,
|
|
265
|
+
thinking,
|
|
266
|
+
domain,
|
|
267
|
+
type,
|
|
268
|
+
dimension,
|
|
269
|
+
unit,
|
|
270
|
+
sampleSize: values.length,
|
|
271
|
+
median: center,
|
|
272
|
+
p90: percentile(values, 0.9),
|
|
273
|
+
medianAbsoluteDeviation: median(deviations),
|
|
274
|
+
latestAt,
|
|
275
|
+
freshness: age <= freshForMs ? ("fresh" as const) : ("stale" as const),
|
|
276
|
+
confidence: Math.min(1, Math.sqrt(values.length / 20)) * recency,
|
|
277
|
+
};
|
|
278
|
+
})
|
|
279
|
+
.sort(
|
|
280
|
+
(left, right) =>
|
|
281
|
+
left.provider.localeCompare(right.provider) ||
|
|
282
|
+
left.model.localeCompare(right.model) ||
|
|
283
|
+
left.domain.localeCompare(right.domain) ||
|
|
284
|
+
left.type.localeCompare(right.type) ||
|
|
285
|
+
left.dimension.localeCompare(right.dimension),
|
|
286
|
+
);
|
|
203
287
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { MODEL_AGGREGATE_MAX_ROWS, MODEL_OBSERVATION_FRESH_MS, MODEL_RANKING_MAX_SOURCES } from "../constants.ts";
|
|
2
2
|
import type { BenchmarkStore } from "../ports/benchmark-store.ts";
|
|
3
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
4
|
import type { ModelTaskDomain, ModelTaskType } from "./model-observation.ts";
|
|
5
|
+
import { aggregateModelMetrics } from "./model-observation.ts";
|
|
6
|
+
import {
|
|
7
|
+
type ModelCandidate,
|
|
8
|
+
type ModelRankingResult,
|
|
9
|
+
rankModelCandidates,
|
|
10
|
+
type ScopeAuthority,
|
|
11
|
+
type UtilityWeights,
|
|
12
|
+
} from "./model-ranking.ts";
|
|
7
13
|
|
|
8
14
|
export interface ModelRecommendationInput {
|
|
9
15
|
candidates: ModelCandidate[];
|
|
@@ -27,7 +33,11 @@ export class EvidenceModelRanker implements ModelRanker {
|
|
|
27
33
|
) {}
|
|
28
34
|
|
|
29
35
|
rank(input: ModelRecommendationInput): ModelRankingResult {
|
|
30
|
-
if (
|
|
36
|
+
if (
|
|
37
|
+
!Array.isArray(input.sourceIds) ||
|
|
38
|
+
input.sourceIds.length > MODEL_RANKING_MAX_SOURCES ||
|
|
39
|
+
!input.sourceIds.every((sourceId) => typeof sourceId === "string" && sourceId.length > 0 && sourceId.length <= 160)
|
|
40
|
+
) {
|
|
31
41
|
throw new Error("benchmark source selection is invalid");
|
|
32
42
|
}
|
|
33
43
|
const sourceIds = [...new Set(input.sourceIds)];
|