@danypops/jittor 0.5.1 → 0.7.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.
Files changed (44) hide show
  1. package/README.md +62 -7
  2. package/docs/BENCHMARK_SOURCES.md +32 -0
  3. package/docs/OUTPUT_CHANNELS.md +31 -0
  4. package/docs/PROVIDER_RESEARCH.md +57 -0
  5. package/extension/src/benchmark-tui.ts +105 -0
  6. package/extension/src/footer.ts +68 -12
  7. package/extension/src/index.ts +263 -36
  8. package/extension/src/tui.ts +61 -9
  9. package/extension/src/usage.ts +165 -47
  10. package/package.json +5 -1
  11. package/src/adapters/metric-benchmark-store.ts +99 -0
  12. package/src/adapters/openrouter-benchmark-index-source.ts +94 -0
  13. package/src/adapters/openrouter-benchmark-source.ts +109 -0
  14. package/src/adapters/sqlite-metric-store.ts +43 -2
  15. package/src/cli.ts +660 -9
  16. package/src/client.ts +12 -44
  17. package/src/constants.ts +69 -5
  18. package/src/daemon.ts +65 -43
  19. package/src/db.ts +13 -30
  20. package/src/domain/benchmark.ts +264 -0
  21. package/src/domain/context-telemetry.ts +31 -0
  22. package/src/domain/metric.ts +46 -5
  23. package/src/domain/model-observation.ts +203 -0
  24. package/src/domain/model-ranking-service.ts +41 -0
  25. package/src/domain/model-ranking.ts +232 -0
  26. package/src/domain/task-cost.ts +70 -0
  27. package/src/domain/task-focus.ts +65 -0
  28. package/src/domain/usage.ts +134 -33
  29. package/src/log.ts +28 -0
  30. package/src/ports/benchmark-controller.ts +11 -0
  31. package/src/ports/benchmark-source.ts +7 -0
  32. package/src/ports/benchmark-store.ts +7 -0
  33. package/src/ports/metric-store.ts +30 -0
  34. package/src/ports/router-controller.ts +1 -0
  35. package/src/providers/anthropic-contracts.ts +127 -0
  36. package/src/providers/google-adc-auth.ts +63 -0
  37. package/src/providers/google-vertex-budget-contracts.ts +181 -0
  38. package/src/providers/google-vertex-budget.ts +127 -0
  39. package/src/providers/google-vertex-contracts.ts +116 -0
  40. package/src/providers/telemetry-sources.ts +35 -0
  41. package/src/router.ts +12 -0
  42. package/src/service.ts +132 -17
  43. package/src/state.ts +31 -57
  44. package/src/version.ts +2 -14
@@ -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
+ }
@@ -1,4 +1,6 @@
1
- export const METRIC_UNITS = ["ratio", "usd", "tokens", "requests", "milliseconds", "count"] as const;
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
- if (input["attributes"] !== undefined && (typeof input["attributes"] !== "object" || input["attributes"] === null || Array.isArray(input["attributes"]))) {
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: (input["attributes"] as Record<string, unknown> | undefined) ?? {},
93
+ attributes,
53
94
  };
54
95
  }
@@ -0,0 +1,203 @@
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
+ /**
11
+ * Two independent axes, not one flat class: "coding" is a subject-matter domain (which
12
+ * benchmark quality evidence applies), while "research"/"planning" are activities that can
13
+ * happen inside any domain (which predict how much reasoning effort a task needs). The prior
14
+ * single ModelTaskClass conflated them -- an agentic/tool-use benchmark (a type signal) was
15
+ * being read as if it were a domain-quality signal. Both axes default to "general" when tool
16
+ * usage carries no distinguishing signal for that axis; a run can score coding on domain and
17
+ * research on type simultaneously (e.g. reading a file, then searching the web in one turn).
18
+ */
19
+ export const TASK_DOMAINS = ["coding", "general"] as const;
20
+ export type ModelTaskDomain = typeof TASK_DOMAINS[number];
21
+ export const TASK_TYPES = ["research", "planning", "general"] as const;
22
+ export type ModelTaskType = typeof TASK_TYPES[number];
23
+ export type ExplicitOutcome = "accepted" | "rejected" | "unknown";
24
+
25
+ export interface ModelRunObservation {
26
+ runId: string;
27
+ provider: string;
28
+ model: string;
29
+ thinking: string;
30
+ domain: ModelTaskDomain;
31
+ type: ModelTaskType;
32
+ startedAt: number;
33
+ firstTokenAt: number | null;
34
+ completedAt: number;
35
+ inputTokens: number;
36
+ outputTokens: number;
37
+ cacheReadTokens: number;
38
+ cacheWriteTokens: number;
39
+ costUsd: number;
40
+ providerResponses: number;
41
+ toolCalls: number;
42
+ toolFailures: number;
43
+ stopReason: "stop" | "length" | "toolUse" | "error" | "aborted" | "unknown";
44
+ explicitOutcome: ExplicitOutcome;
45
+ }
46
+
47
+ export interface ModelMetricAggregate {
48
+ provider: string;
49
+ model: string;
50
+ thinking: string;
51
+ domain: ModelTaskDomain;
52
+ type: ModelTaskType;
53
+ dimension: string;
54
+ unit: MetricUnit;
55
+ sampleSize: number;
56
+ median: number;
57
+ p90: number;
58
+ medianAbsoluteDeviation: number;
59
+ latestAt: number;
60
+ freshness: "fresh" | "stale";
61
+ confidence: number;
62
+ }
63
+
64
+ export interface ModelAggregateOptions {
65
+ now?: number;
66
+ freshForMs?: number;
67
+ }
68
+
69
+ const ALLOWED_FIELDS = new Set<keyof ModelRunObservation>([
70
+ "runId", "provider", "model", "thinking", "domain", "type", "startedAt", "firstTokenAt", "completedAt",
71
+ "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd", "providerResponses",
72
+ "toolCalls", "toolFailures", "stopReason", "explicitOutcome",
73
+ ]);
74
+ const STOP_REASONS = new Set<ModelRunObservation["stopReason"]>(["stop", "length", "toolUse", "error", "aborted", "unknown"]);
75
+ const OUTCOMES = new Set<ExplicitOutcome>(["accepted", "rejected", "unknown"]);
76
+
77
+ 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)) throw new Error(`${name} is invalid`);
79
+ return value;
80
+ }
81
+
82
+ function nonNegative(value: unknown, name: string, integer = false): number {
83
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || (integer && !Number.isSafeInteger(value))) throw new Error(`${name} is invalid`);
84
+ return value;
85
+ }
86
+
87
+ export function validateModelRunObservation(value: unknown): ModelRunObservation {
88
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("model run observation must be an object");
89
+ const input = value as Record<string, unknown>;
90
+ for (const key of Object.keys(input)) if (!ALLOWED_FIELDS.has(key as keyof ModelRunObservation)) throw new Error(`unsupported field: ${key}`);
91
+ const identity = normalizeModelIdentity(text(input["provider"], "provider"), text(input["model"], "model"));
92
+ const startedAt = nonNegative(input["startedAt"], "start time", true);
93
+ const completedAt = nonNegative(input["completedAt"], "completion time", true);
94
+ const firstTokenAt = input["firstTokenAt"] === null ? null : nonNegative(input["firstTokenAt"], "first-token time", true);
95
+ if (completedAt < startedAt || (firstTokenAt !== null && (firstTokenAt < startedAt || firstTokenAt > completedAt))) throw new Error("model run timestamps are not ordered");
96
+ if (!TASK_DOMAINS.includes(input["domain"] as ModelTaskDomain)) throw new Error("task domain is invalid");
97
+ if (!TASK_TYPES.includes(input["type"] as ModelTaskType)) throw new Error("task type is invalid");
98
+ if (!STOP_REASONS.has(input["stopReason"] as ModelRunObservation["stopReason"])) throw new Error("stop reason is invalid");
99
+ if (!OUTCOMES.has(input["explicitOutcome"] as ExplicitOutcome)) throw new Error("explicit outcome is invalid");
100
+ const providerResponses = nonNegative(input["providerResponses"], "provider response count", true);
101
+ const toolCalls = nonNegative(input["toolCalls"], "tool call count", true);
102
+ const toolFailures = nonNegative(input["toolFailures"], "tool failure count", true);
103
+ if (providerResponses < 1 || toolFailures > toolCalls) throw new Error("model run counters are inconsistent");
104
+ return {
105
+ runId: text(input["runId"], "run id"), provider: identity.provider, model: identity.model,
106
+ thinking: text(input["thinking"], "thinking level"), domain: input["domain"] as ModelTaskDomain, type: input["type"] as ModelTaskType,
107
+ startedAt, firstTokenAt, completedAt,
108
+ inputTokens: nonNegative(input["inputTokens"], "input tokens"),
109
+ outputTokens: nonNegative(input["outputTokens"], "output tokens"),
110
+ cacheReadTokens: nonNegative(input["cacheReadTokens"], "cache read tokens"),
111
+ cacheWriteTokens: nonNegative(input["cacheWriteTokens"], "cache write tokens"),
112
+ costUsd: nonNegative(input["costUsd"], "cost"), providerResponses, toolCalls, toolFailures,
113
+ stopReason: input["stopReason"] as ModelRunObservation["stopReason"], explicitOutcome: input["explicitOutcome"] as ExplicitOutcome,
114
+ };
115
+ }
116
+
117
+ export interface ModelTaskClassification {
118
+ domain: ModelTaskDomain;
119
+ type: ModelTaskType;
120
+ }
121
+
122
+ /** 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
+ export function classifyTaskFromTools(toolNames: string[]): ModelTaskClassification {
124
+ 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)) ? "coding" : "general";
126
+ const type: ModelTaskType = ["web_fetch", "web_search"].some((name) => names.has(name))
127
+ ? "research"
128
+ : ["tasks", "papyrus_create", "papyrus_graph"].some((name) => names.has(name)) ? "planning" : "general";
129
+ return { domain, type };
130
+ }
131
+
132
+ export function modelRunMetrics(value: ModelRunObservation): MetricObservation[] {
133
+ const run = validateModelRunObservation(value);
134
+ const scope = `${run.provider}/${run.model}`;
135
+ const attributes = { provider: run.provider, model: run.model, thinking: run.thinking, domain: run.domain, type: run.type, runId: run.runId };
136
+ const metric = (name: string, amount: number, unit: MetricUnit): MetricObservation => ({ source: "local-model", scope, metric: name, value: amount, unit, observedAt: run.completedAt, attributes });
137
+ const wallMs = run.completedAt - run.startedAt;
138
+ const totalInput = run.inputTokens + run.cacheReadTokens;
139
+ const metrics: MetricObservation[] = [];
140
+ if (run.firstTokenAt !== null) metrics.push(metric("ttft", run.firstTokenAt - run.startedAt, "milliseconds"));
141
+ metrics.push(
142
+ metric("wall-latency", wallMs, "milliseconds"),
143
+ metric("output-throughput", wallMs === 0 ? 0 : run.outputTokens / (wallMs / 1_000), "tokens-per-second"),
144
+ metric("input-tokens", run.inputTokens, "tokens"),
145
+ metric("output-tokens", run.outputTokens, "tokens"),
146
+ metric("cache-read-tokens", run.cacheReadTokens, "tokens"),
147
+ metric("cache-write-tokens", run.cacheWriteTokens, "tokens"),
148
+ metric("cache-read-ratio", totalInput === 0 ? 0 : run.cacheReadTokens / totalInput, "ratio"),
149
+ metric("cost", run.costUsd, "usd"),
150
+ metric("provider-responses", run.providerResponses, "count"),
151
+ metric("retry-count", Math.max(0, run.providerResponses - 1), "count"),
152
+ metric("tool-calls", run.toolCalls, "count"),
153
+ metric("tool-failures", run.toolFailures, "count"),
154
+ metric("failure", run.stopReason === "error" ? 1 : 0, "ratio"),
155
+ );
156
+ if (run.explicitOutcome !== "unknown") metrics.push(metric("outcome-accepted", run.explicitOutcome === "accepted" ? 1 : 0, "ratio"));
157
+ return metrics;
158
+ }
159
+
160
+ function percentile(sorted: number[], fraction: number): number {
161
+ return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]!;
162
+ }
163
+
164
+ function median(sorted: number[]): number {
165
+ const middle = Math.floor(sorted.length / 2);
166
+ return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!;
167
+ }
168
+
169
+ export function aggregateModelMetrics(input: StoredMetricObservation[], options: ModelAggregateOptions = {}): ModelMetricAggregate[] {
170
+ const now = options.now ?? Date.now();
171
+ const freshForMs = options.freshForMs ?? MODEL_OBSERVATION_FRESH_MS;
172
+ if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(freshForMs) || freshForMs <= 0) throw new Error("aggregate time bounds are invalid");
173
+ const groups = new Map<string, StoredMetricObservation[]>();
174
+ for (const row of input.slice(0, MODEL_AGGREGATE_MAX_ROWS)) {
175
+ if (row.source !== "local-model" || typeof row.value !== "number" || !Number.isFinite(row.value)) continue;
176
+ const provider = row.attributes["provider"];
177
+ const model = row.attributes["model"];
178
+ const thinking = row.attributes["thinking"];
179
+ const domain = row.attributes["domain"];
180
+ const type = row.attributes["type"];
181
+ if (typeof provider !== "string" || typeof model !== "string" || typeof thinking !== "string" || !TASK_DOMAINS.includes(domain as ModelTaskDomain) || !TASK_TYPES.includes(type as ModelTaskType)) continue;
182
+ const key = JSON.stringify([provider, model, thinking, domain, type, row.metric, row.unit]);
183
+ if (!groups.has(key) && groups.size >= MODEL_AGGREGATE_MAX_GROUPS) continue;
184
+ const rows = groups.get(key) ?? [];
185
+ rows.push(row);
186
+ groups.set(key, rows);
187
+ }
188
+ return [...groups.entries()].map(([key, rows]) => {
189
+ const [provider, model, thinking, domain, type, dimension, unit] = JSON.parse(key) as [string, string, string, ModelTaskDomain, ModelTaskType, string, MetricUnit];
190
+ const values = rows.map((row) => row.value as number).sort((left, right) => left - right);
191
+ const center = median(values);
192
+ const deviations = values.map((value) => Math.abs(value - center)).sort((left, right) => left - right);
193
+ const latestAt = Math.max(...rows.map((row) => row.observedAt));
194
+ const age = Math.max(0, now - latestAt);
195
+ const recency = Math.max(0, 1 - (age / freshForMs));
196
+ return {
197
+ provider, model, thinking, domain, type, dimension, unit,
198
+ sampleSize: values.length, median: center, p90: percentile(values, 0.9), medianAbsoluteDeviation: median(deviations), latestAt,
199
+ freshness: age <= freshForMs ? "fresh" as const : "stale" as const,
200
+ confidence: Math.min(1, Math.sqrt(values.length / 20)) * recency,
201
+ };
202
+ }).sort((left, right) => left.provider.localeCompare(right.provider) || left.model.localeCompare(right.model) || left.domain.localeCompare(right.domain) || left.type.localeCompare(right.type) || left.dimension.localeCompare(right.dimension));
203
+ }
@@ -0,0 +1,41 @@
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 { ModelTaskDomain, ModelTaskType } from "./model-observation.ts";
7
+
8
+ export interface ModelRecommendationInput {
9
+ candidates: ModelCandidate[];
10
+ scopeAuthority: ScopeAuthority;
11
+ domain: ModelTaskDomain;
12
+ type: ModelTaskType;
13
+ budgetPressure: number;
14
+ weights: UtilityWeights;
15
+ sourceIds: string[];
16
+ }
17
+
18
+ export interface ModelRanker {
19
+ rank(input: ModelRecommendationInput): ModelRankingResult;
20
+ }
21
+
22
+ export class EvidenceModelRanker implements ModelRanker {
23
+ constructor(
24
+ private readonly benchmarks: BenchmarkStore,
25
+ private readonly metrics: MetricStore,
26
+ private readonly clock: () => number = Date.now,
27
+ ) {}
28
+
29
+ rank(input: ModelRecommendationInput): ModelRankingResult {
30
+ 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)) {
31
+ throw new Error("benchmark source selection is invalid");
32
+ }
33
+ const sourceIds = [...new Set(input.sourceIds)];
34
+ const { sourceIds: _sourceIds, ...rankingInput } = input;
35
+ const externalEvidence = sourceIds.flatMap((sourceId) => this.benchmarks.latest(sourceId)?.observations ?? []);
36
+ const localRows = this.metrics.query({ source: "local-model", order: "desc", limit: MODEL_AGGREGATE_MAX_ROWS });
37
+ const now = this.clock();
38
+ const localEvidence = aggregateModelMetrics(localRows, { now, freshForMs: MODEL_OBSERVATION_FRESH_MS });
39
+ return rankModelCandidates({ ...rankingInput, externalEvidence, localEvidence, now });
40
+ }
41
+ }
@@ -0,0 +1,232 @@
1
+ import { BENCHMARK_MAX_OBSERVATIONS_PER_SNAPSHOT, MAX_DYNAMIC_ROUTES, MODEL_AGGREGATE_MAX_ROWS } from "../constants.ts";
2
+ import { normalizeModelIdentity, type BenchmarkObservation } from "./benchmark.ts";
3
+ import { TASK_DOMAINS, TASK_TYPES, type ModelMetricAggregate, type ModelTaskDomain, type ModelTaskType } from "./model-observation.ts";
4
+
5
+ export type ScopeAuthority = "exact-session" | "available-models";
6
+ export type UtilityComponentName = "quality" | "cost" | "latency" | "context" | "reliability";
7
+
8
+ export interface ModelCandidate {
9
+ provider: string;
10
+ model: string;
11
+ thinking: string;
12
+ }
13
+
14
+ export interface UtilityWeights {
15
+ quality: number;
16
+ cost: number;
17
+ latency: number;
18
+ context: number;
19
+ reliability: number;
20
+ }
21
+
22
+ export interface ModelRankingInput {
23
+ candidates: ModelCandidate[];
24
+ scopeAuthority: ScopeAuthority;
25
+ domain: ModelTaskDomain;
26
+ type: ModelTaskType;
27
+ budgetPressure: number;
28
+ weights: UtilityWeights;
29
+ externalEvidence: BenchmarkObservation[];
30
+ localEvidence: ModelMetricAggregate[];
31
+ now: number;
32
+ }
33
+
34
+ export interface UtilityComponent {
35
+ name: UtilityComponentName;
36
+ score: number | null;
37
+ confidence: number;
38
+ weight: number;
39
+ evidenceCount: number;
40
+ reason: string;
41
+ }
42
+
43
+ export interface RankingProvenance {
44
+ sourceId: string;
45
+ publisher: string;
46
+ url: string;
47
+ revision: string;
48
+ freshness: "fresh" | "stale";
49
+ }
50
+
51
+ export interface RankedModel {
52
+ candidate: ModelCandidate;
53
+ identity: string;
54
+ utility: number | null;
55
+ confidence: number;
56
+ components: UtilityComponent[];
57
+ provenance: RankingProvenance[];
58
+ trace: string[];
59
+ }
60
+
61
+ export interface ModelRankingResult {
62
+ scopeAuthority: ScopeAuthority;
63
+ scopeWarning: string | null;
64
+ domain: ModelTaskDomain;
65
+ type: ModelTaskType;
66
+ completeness: "complete" | "partial" | "insufficient-evidence";
67
+ ranked: RankedModel[];
68
+ automaticSelection: ModelCandidate | null;
69
+ }
70
+
71
+ interface RawComponent {
72
+ value: number | null;
73
+ confidence: number;
74
+ evidenceCount: number;
75
+ reason: string;
76
+ lowerIsBetter: boolean;
77
+ }
78
+
79
+ const COMPONENTS: UtilityComponentName[] = ["quality", "cost", "latency", "context", "reliability"];
80
+
81
+ function finiteBound(value: number, name: string, minimum: number, maximum: number): number {
82
+ if (!Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`${name} is outside its supported range`);
83
+ return value;
84
+ }
85
+
86
+ function candidateIdentity(candidate: ModelCandidate): string {
87
+ const identity = normalizeModelIdentity(candidate.provider, candidate.model);
88
+ if (typeof candidate.thinking !== "string" || candidate.thinking.length === 0 || candidate.thinking.length > 160) throw new Error("candidate thinking level is invalid");
89
+ return `${identity.canonical}:${candidate.thinking}`;
90
+ }
91
+
92
+ function average(values: number[]): number {
93
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
94
+ }
95
+
96
+ function externalValues(candidate: ModelCandidate, evidence: BenchmarkObservation[], dimensions: string[], now: number): { values: number[]; confidences: number[]; provenance: RankingProvenance[] } {
97
+ const identity = normalizeModelIdentity(candidate.provider, candidate.model);
98
+ const matching = evidence.filter((item) => (item.model.canonical === identity.canonical || item.model.aliases.includes(identity.canonical)) && dimensions.includes(item.dimension));
99
+ return {
100
+ values: matching.map((item) => item.value),
101
+ confidences: matching.map((item) => item.provenance.confidence * (now <= item.provenance.freshUntil ? 1 : 0.25)),
102
+ provenance: matching.map((item) => ({ sourceId: item.provenance.sourceId, publisher: item.provenance.publisher, url: item.provenance.url, revision: item.provenance.revision, freshness: now <= item.provenance.freshUntil ? "fresh" : "stale" })),
103
+ };
104
+ }
105
+
106
+ function localValues(candidate: ModelCandidate, domain: ModelTaskDomain, type: ModelTaskType, evidence: ModelMetricAggregate[], dimension: string): ModelMetricAggregate[] {
107
+ const identity = normalizeModelIdentity(candidate.provider, candidate.model);
108
+ return evidence.filter((item) => item.provider === identity.provider && item.model === identity.model && item.thinking === candidate.thinking && item.domain === domain && item.type === type && item.dimension === dimension);
109
+ }
110
+
111
+ /**
112
+ * quality-{domain} is a subject-matter signal (e.g. quality-coding from a coding benchmark);
113
+ * quality-type-{type} is an activity signal (e.g. quality-type-planning from an agentic/tool-use
114
+ * benchmark). Both are optional and additive on top of the universal quality-general fallback --
115
+ * a candidate with no domain- or type-specific evidence still gets ranked on general quality
116
+ * rather than being treated as having zero evidence.
117
+ */
118
+ function qualityDimensions(domain: ModelTaskDomain, type: ModelTaskType): string[] {
119
+ const dimensions: string[] = [];
120
+ if (domain !== "general") dimensions.push(`quality-${domain}`);
121
+ if (type !== "general") dimensions.push(`quality-type-${type}`);
122
+ dimensions.push("quality-general");
123
+ return dimensions;
124
+ }
125
+
126
+ function rawComponents(candidate: ModelCandidate, input: ModelRankingInput): { components: Record<UtilityComponentName, RawComponent>; provenance: RankingProvenance[] } {
127
+ const quality = externalValues(candidate, input.externalEvidence, qualityDimensions(input.domain, input.type), input.now);
128
+ const priceInput = externalValues(candidate, input.externalEvidence, ["price-input"], input.now);
129
+ const priceOutput = externalValues(candidate, input.externalEvidence, ["price-output"], input.now);
130
+ const measuredLatency = externalValues(candidate, input.externalEvidence, ["latency"], input.now);
131
+ const rankedLatency = externalValues(candidate, input.externalEvidence, ["latency-rank", "throughput-rank"], input.now);
132
+ const latency = measuredLatency.values.length > 0 ? measuredLatency : rankedLatency;
133
+ const context = externalValues(candidate, input.externalEvidence, ["context-window"], input.now);
134
+ const localLatency = localValues(candidate, input.domain, input.type, input.localEvidence, "wall-latency");
135
+ const failures = localValues(candidate, input.domain, input.type, input.localEvidence, "failure");
136
+ const outcomes = localValues(candidate, input.domain, input.type, input.localEvidence, "outcome-accepted");
137
+ const qualityValues = quality.values;
138
+ const prices = [...priceInput.values, ...priceOutput.values];
139
+ const latencyValues = localLatency.length > 0 ? localLatency.map((item) => item.median) : latency.values;
140
+ const latencyConfidences = localLatency.length > 0 ? localLatency.map((item) => item.confidence) : latency.confidences;
141
+ const reliabilityValues = [...failures.map((item) => 1 - item.median), ...outcomes.map((item) => item.median)];
142
+ const withEvidence = (values: number[], confidences: number[], lowerIsBetter: boolean, label: string): RawComponent => values.length === 0
143
+ ? { value: null, confidence: 0, evidenceCount: 0, reason: `${label} evidence is missing`, lowerIsBetter }
144
+ : {
145
+ value: average(values),
146
+ confidence: average(confidences) / (1 + ((Math.max(...values) - Math.min(...values)) / Math.max(Math.abs(average(values)), Number.EPSILON))),
147
+ evidenceCount: values.length,
148
+ reason: `${values.length} ${label} observation${values.length === 1 ? "" : "s"}`,
149
+ lowerIsBetter,
150
+ };
151
+ const components: Record<UtilityComponentName, RawComponent> = {
152
+ quality: withEvidence(qualityValues, quality.confidences, false, "task quality"),
153
+ cost: withEvidence(prices, [...priceInput.confidences, ...priceOutput.confidences], true, "price"),
154
+ latency: withEvidence(latencyValues, latencyConfidences, true, "latency"),
155
+ context: withEvidence(context.values, context.confidences, false, "context window"),
156
+ reliability: withEvidence(reliabilityValues, [...failures, ...outcomes].map((item) => item.confidence), false, "local reliability"),
157
+ };
158
+ return { components, provenance: [...quality.provenance, ...priceInput.provenance, ...priceOutput.provenance, ...latency.provenance, ...context.provenance] };
159
+ }
160
+
161
+ function normalizedScore(value: number, values: number[], lowerIsBetter: boolean): number {
162
+ const minimum = Math.min(...values);
163
+ const maximum = Math.max(...values);
164
+ if (maximum === minimum) return 0.5;
165
+ const score = (value - minimum) / (maximum - minimum);
166
+ return lowerIsBetter ? 1 - score : score;
167
+ }
168
+
169
+ export function rankModelCandidates(value: ModelRankingInput): ModelRankingResult {
170
+ if (!Array.isArray(value.candidates) || value.candidates.length === 0 || value.candidates.length > MAX_DYNAMIC_ROUTES) throw new Error("candidate count is outside its supported range");
171
+ if (!Array.isArray(value.externalEvidence) || value.externalEvidence.length > BENCHMARK_MAX_OBSERVATIONS_PER_SNAPSHOT * 4) throw new Error("external evidence exceeds the supported bound");
172
+ if (!Array.isArray(value.localEvidence) || value.localEvidence.length > MODEL_AGGREGATE_MAX_ROWS) throw new Error("local evidence exceeds the supported bound");
173
+ if (value.scopeAuthority !== "exact-session" && value.scopeAuthority !== "available-models") throw new Error("scope authority is invalid");
174
+ if (!TASK_DOMAINS.includes(value.domain)) throw new Error("task domain is invalid");
175
+ if (!TASK_TYPES.includes(value.type)) throw new Error("task type is invalid");
176
+ if (!Number.isSafeInteger(value.now) || value.now <= 0) throw new Error("ranking time is invalid");
177
+ const budgetPressure = finiteBound(value.budgetPressure, "budget pressure", 0, 2);
178
+ const weights = Object.fromEntries(COMPONENTS.map((name) => [name, finiteBound(value.weights[name], `${name} weight`, 0, 10)])) as unknown as UtilityWeights;
179
+ const seen = new Set<string>();
180
+ const candidates = value.candidates.map((candidate) => ({ ...candidate })).filter((candidate) => {
181
+ const identity = candidateIdentity(candidate);
182
+ if (seen.has(identity)) return false;
183
+ seen.add(identity);
184
+ return true;
185
+ });
186
+ const raw = candidates.map((candidate) => rawComponents(candidate, value));
187
+ const effectiveWeights: UtilityWeights = { ...weights, cost: weights.cost * (1 + budgetPressure) };
188
+ const ranked = candidates.map((candidate, index): RankedModel => {
189
+ const source = raw[index]!;
190
+ const components = COMPONENTS.map((name): UtilityComponent => {
191
+ const component = source.components[name];
192
+ const comparable = raw.map((item) => item.components[name].value).filter((item): item is number => item !== null);
193
+ return {
194
+ name,
195
+ score: component.value === null ? null : normalizedScore(component.value, comparable, component.lowerIsBetter),
196
+ confidence: component.confidence,
197
+ weight: effectiveWeights[name],
198
+ evidenceCount: component.evidenceCount,
199
+ reason: component.reason,
200
+ };
201
+ });
202
+ const known = components.filter((component): component is UtilityComponent & { score: number } => component.score !== null && component.weight > 0);
203
+ const knownWeight = known.reduce((sum, component) => sum + component.weight, 0);
204
+ const totalWeight = components.reduce((sum, component) => sum + component.weight, 0);
205
+ const utility = knownWeight === 0 ? null : known.reduce((sum, component) => sum + (component.score * component.weight), 0) / knownWeight;
206
+ const confidence = totalWeight === 0 ? 0 : known.reduce((sum, component) => sum + (component.confidence * component.weight), 0) / totalWeight;
207
+ const provenance = [...new Map(source.provenance.map((item) => [`${item.sourceId}:${item.revision}:${item.url}`, item])).values()]
208
+ .sort((left, right) => left.sourceId.localeCompare(right.sourceId) || left.revision.localeCompare(right.revision));
209
+ return {
210
+ candidate,
211
+ identity: candidateIdentity(candidate),
212
+ utility,
213
+ confidence,
214
+ components,
215
+ provenance,
216
+ trace: [`domain ${value.domain}, type ${value.type}`, `budget pressure ${budgetPressure.toFixed(3)} makes cost weight ${effectiveWeights.cost.toFixed(3)}`, `${known.length}/${components.length} utility components have evidence`, `scope authority ${value.scopeAuthority}`],
217
+ };
218
+ }).sort((left, right) => (right.utility ?? -1) - (left.utility ?? -1) || right.confidence - left.confidence || left.identity.localeCompare(right.identity));
219
+ const knownComponents = ranked.reduce((sum, item) => sum + item.components.filter((component) => component.score !== null).length, 0);
220
+ const possibleComponents = ranked.length * COMPONENTS.length;
221
+ const completeness = knownComponents === 0 ? "insufficient-evidence" : knownComponents === possibleComponents ? "complete" : "partial";
222
+ const exact = value.scopeAuthority === "exact-session";
223
+ return {
224
+ scopeAuthority: value.scopeAuthority,
225
+ scopeWarning: exact ? null : "Pi available models are not the exact session scope; automatic selection is disabled",
226
+ domain: value.domain,
227
+ type: value.type,
228
+ completeness,
229
+ ranked,
230
+ automaticSelection: exact && ranked[0]?.utility !== null ? ranked[0]!.candidate : null,
231
+ };
232
+ }