catherd-cli 1.1.1 → 1.2.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 +40 -14
- package/catalog/ATTRIBUTION.md +36 -0
- package/catalog/models.json +10 -0
- package/catalog/scores.json +3006 -194
- package/catalog/sources.json +86 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +2 -2
- package/src/domain/bars.ts +119 -0
- package/src/domain/calibration.ts +125 -0
- package/src/domain/catalog.ts +210 -23
- package/src/domain/failover.ts +14 -1
- package/src/domain/profile-rules.ts +108 -18
- package/src/domain/sources.ts +144 -0
- package/src/entry/catalog-command.ts +163 -8
- package/src/entry/init-command.ts +72 -2
- package/src/entry/mcp/lane-tools.ts +1 -1
- package/src/entry/mcp/server.ts +13 -3
- package/src/entry/mcp/setup-tools.ts +24 -2
- package/src/entry/profile-command.ts +10 -4
- package/src/entry/tui/commands.ts +20 -2
- package/src/entry/tui/effects.ts +50 -1
- package/src/entry/tui/fixtures.ts +31 -3
- package/src/entry/tui/profile-edits.ts +17 -9
- package/src/entry/tui/profile-tree.ts +2 -2
- package/src/entry/tui/state.ts +4 -0
- package/src/entry/tui/views/profile-actions.ts +9 -1
- package/src/entry/tui/views/profiles.tsx +81 -14
- package/src/entry/tui/views/save-dialog.tsx +12 -4
- package/src/infra/env.ts +2 -0
- package/src/infra/jev-client.ts +88 -40
- package/src/infra/paths.ts +2 -0
- package/src/infra/sources/arena.ts +87 -0
- package/src/infra/sources/artificial-analysis.ts +112 -0
- package/src/infra/sources/cache.ts +124 -0
- package/src/infra/sources/csv.ts +38 -0
- package/src/infra/sources/epoch.ts +82 -0
- package/src/infra/sources/http.ts +75 -0
- package/src/infra/sources/litellm.ts +58 -0
- package/src/infra/sources/models-dev.ts +59 -0
- package/src/infra/sources/openrouter-endpoints.ts +52 -0
- package/src/infra/sources/openrouter-models.ts +60 -0
- package/src/infra/sources/rows.ts +38 -0
- package/src/infra/sources/vectara.ts +69 -0
- package/src/infra/sources/zip.ts +48 -0
- package/src/services/catalog-refresh.ts +200 -0
- package/src/services/catalog-service.ts +83 -23
- package/src/services/credentials.ts +69 -0
- package/src/services/doctor-sources.ts +76 -0
- package/src/services/doctor.ts +10 -0
- package/src/services/jev-service.ts +14 -41
- package/src/services/ports.ts +6 -0
- package/src/services/profile-service.ts +8 -6
- package/src/services/provenance.ts +115 -0
- package/src/services/routing-service.ts +7 -2
- package/src/services/run-evidence.ts +86 -0
- package/src/services/source-derive.ts +349 -0
- package/src/services/source-sync.ts +305 -0
- package/src/services/standins.ts +204 -0
- package/src/services/treat-likes.ts +84 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { median } from "../../domain/util.ts";
|
|
2
|
+
import { SourceError, type SourceTransport, sourceGet } from "./http.ts";
|
|
3
|
+
import { isoDay, num, type SourceRow } from "./rows.ts";
|
|
4
|
+
|
|
5
|
+
/** Spec 1.2 §3.1: per provider, uptime, latency and throughput over the last 30 minutes. */
|
|
6
|
+
export const openRouterEndpointsUrl = (id: string): string =>
|
|
7
|
+
`https://openrouter.ai/api/v1/models/${id}/endpoints`;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Plan 13 R-H: one request per OpenRouter id (one per catalog family OpenRouter lists), in parallel. An id
|
|
11
|
+
* OpenRouter no longer knows (404) is left out; any other failure fails the source, which keeps its last answer.
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchOpenRouterEndpoints(
|
|
14
|
+
ids: readonly string[],
|
|
15
|
+
t: SourceTransport = {},
|
|
16
|
+
): Promise<Record<string, unknown>> {
|
|
17
|
+
const got = await Promise.all(
|
|
18
|
+
ids.map(async (id) => {
|
|
19
|
+
try {
|
|
20
|
+
return [id, (await sourceGet(openRouterEndpointsUrl(id), t)).json()] as const;
|
|
21
|
+
} catch (e) {
|
|
22
|
+
if (e instanceof SourceError && e.status === 404) return null;
|
|
23
|
+
throw e instanceof SourceError ? new SourceError(`${id}: ${e.message}`, e.status, e.headers) : e;
|
|
24
|
+
}
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
return Object.fromEntries(got.filter((x) => x !== null));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const ENDPOINT_FIELDS = ["latency_last_30m", "throughput_last_30m", "uptime_last_30m"] as const;
|
|
31
|
+
|
|
32
|
+
/** A statistic as a number, or as a percentile object (`{ p50: … }`) some answers carry. */
|
|
33
|
+
const stat = (v: unknown): number | null =>
|
|
34
|
+
v !== null && typeof v === "object" ? num((v as { p50?: unknown }).p50) : num(v);
|
|
35
|
+
|
|
36
|
+
/** One row per OpenRouter id and field: the median over its endpoints that report the field. */
|
|
37
|
+
export function parseOpenRouterEndpoints(raw: Record<string, unknown>, fetchedAt: string): SourceRow[] {
|
|
38
|
+
const rows: SourceRow[] = [];
|
|
39
|
+
for (const [id, answer] of Object.entries(raw)) {
|
|
40
|
+
const endpoints = (answer as { data?: { endpoints?: unknown[] } } | null)?.data?.endpoints;
|
|
41
|
+
if (!Array.isArray(endpoints)) continue;
|
|
42
|
+
for (const field of ENDPOINT_FIELDS) {
|
|
43
|
+
const xs = endpoints
|
|
44
|
+
.map((e) => stat((e as Record<string, unknown> | null)?.[field]))
|
|
45
|
+
.filter((x): x is number => x !== null);
|
|
46
|
+
const value = median(xs);
|
|
47
|
+
if (value !== null)
|
|
48
|
+
rows.push({ rung: id, field, value, date: isoDay(fetchedAt), url: `https://openrouter.ai/${id}` });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return rows;
|
|
52
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type SourceTransport, sourceGet } from "./http.ts";
|
|
3
|
+
import { num } from "./rows.ts";
|
|
4
|
+
|
|
5
|
+
/** Spec 1.2 §3.1: price and context cross-check; a model appears here the day it ships. */
|
|
6
|
+
export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
7
|
+
|
|
8
|
+
export const fetchOpenRouterModels = async (t: SourceTransport = {}): Promise<unknown> =>
|
|
9
|
+
(await sourceGet(OPENROUTER_MODELS_URL, t)).json();
|
|
10
|
+
|
|
11
|
+
export interface OpenRouterModel {
|
|
12
|
+
/** `author/slug`, as the endpoints path takes it */
|
|
13
|
+
id: string;
|
|
14
|
+
context: number | null;
|
|
15
|
+
/** dollars per million tokens */
|
|
16
|
+
price: { input: number; output: number; cached: number | null } | null;
|
|
17
|
+
efforts: string[] | null;
|
|
18
|
+
defaultEffort: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ModelSchema = z.looseObject({
|
|
22
|
+
id: z.string(),
|
|
23
|
+
context_length: z.unknown(),
|
|
24
|
+
pricing: z
|
|
25
|
+
.looseObject({ prompt: z.unknown(), completion: z.unknown(), input_cache_read: z.unknown() })
|
|
26
|
+
.optional(),
|
|
27
|
+
reasoning: z
|
|
28
|
+
.looseObject({ supported_efforts: z.array(z.string()).optional(), default_effort: z.string().optional() })
|
|
29
|
+
.optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** A per-token price (a number or decimal string) per million tokens, without float noise (0.000002 × 1e6). */
|
|
33
|
+
export const perMillion = (v: unknown): number | null => {
|
|
34
|
+
const n = num(v);
|
|
35
|
+
return n === null ? null : Math.round(n * 1e12) / 1e6;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Every model of OpenRouter's `/models` answer; a malformed entry is skipped. */
|
|
39
|
+
export function parseOpenRouterModels(raw: unknown): OpenRouterModel[] {
|
|
40
|
+
const data = (raw as { data?: unknown[] } | null)?.data;
|
|
41
|
+
if (!Array.isArray(data)) return [];
|
|
42
|
+
const out: OpenRouterModel[] = [];
|
|
43
|
+
for (const entry of data) {
|
|
44
|
+
const m = ModelSchema.safeParse(entry);
|
|
45
|
+
if (!m.success) continue;
|
|
46
|
+
const input = perMillion(m.data.pricing?.prompt);
|
|
47
|
+
const output = perMillion(m.data.pricing?.completion);
|
|
48
|
+
out.push({
|
|
49
|
+
id: m.data.id,
|
|
50
|
+
context: num(m.data.context_length),
|
|
51
|
+
price:
|
|
52
|
+
input !== null && output !== null
|
|
53
|
+
? { input, output, cached: perMillion(m.data.pricing?.input_cache_read) }
|
|
54
|
+
: null,
|
|
55
|
+
efforts: m.data.reasoning?.supported_efforts ?? null,
|
|
56
|
+
defaultEffort: m.data.reasoning?.default_effort ?? null,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec 1.2 §3.3: what a score source's parser returns, one number each. `rung` is the source's own model id,
|
|
3
|
+
* with `#<effort>` when the source names one (catherd's effort word); `date` is the day the source measured or
|
|
4
|
+
* published it, else the day it was fetched; `url` is where the value can be checked.
|
|
5
|
+
*/
|
|
6
|
+
export interface SourceRow {
|
|
7
|
+
rung: string;
|
|
8
|
+
field: string;
|
|
9
|
+
value: number;
|
|
10
|
+
date: string;
|
|
11
|
+
url: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The `YYYY-MM-DD` of an ISO time. */
|
|
15
|
+
export const isoDay = (iso: string): string => iso.slice(0, 10);
|
|
16
|
+
|
|
17
|
+
/** `candidate` when it is an http(s) URL, else `fallback`. */
|
|
18
|
+
export const urlOr = (candidate: unknown, fallback: string): string =>
|
|
19
|
+
typeof candidate === "string" && /^https?:\/\//.test(candidate.trim()) && URL.canParse(candidate.trim())
|
|
20
|
+
? candidate.trim()
|
|
21
|
+
: fallback;
|
|
22
|
+
|
|
23
|
+
/** `v` as a finite number (numbers and numeric strings), else null. */
|
|
24
|
+
export function num(v: unknown): number | null {
|
|
25
|
+
const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : Number.NaN;
|
|
26
|
+
return Number.isFinite(n) ? n : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One row per rung and field: a source that lists a model twice (Epoch: one row per agent) keeps the highest. */
|
|
30
|
+
export function keepHighest(rows: SourceRow[]): SourceRow[] {
|
|
31
|
+
const best = new Map<string, SourceRow>();
|
|
32
|
+
for (const r of rows) {
|
|
33
|
+
const k = `${r.rung}\u0000${r.field}`;
|
|
34
|
+
const had = best.get(k);
|
|
35
|
+
if (!had || r.value > had.value) best.set(k, r);
|
|
36
|
+
}
|
|
37
|
+
return [...best.values()];
|
|
38
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type SourceTransport, sourceGet } from "./http.ts";
|
|
2
|
+
import { isoDay, num, type SourceRow } from "./rows.ts";
|
|
3
|
+
|
|
4
|
+
/** Spec 1.2 §3.1: the leaderboard is a markdown table in the repository's README (Apache-2.0). */
|
|
5
|
+
export const VECTARA_URL =
|
|
6
|
+
"https://raw.githubusercontent.com/vectara/hallucination-leaderboard/main/README.md";
|
|
7
|
+
export const VECTARA_PAGE = "https://github.com/vectara/hallucination-leaderboard";
|
|
8
|
+
|
|
9
|
+
export const fetchVectara = async (t: SourceTransport = {}): Promise<string> =>
|
|
10
|
+
(await sourceGet(VECTARA_URL, t)).text();
|
|
11
|
+
|
|
12
|
+
const MONTHS = [
|
|
13
|
+
"january",
|
|
14
|
+
"february",
|
|
15
|
+
"march",
|
|
16
|
+
"april",
|
|
17
|
+
"may",
|
|
18
|
+
"june",
|
|
19
|
+
"july",
|
|
20
|
+
"august",
|
|
21
|
+
"september",
|
|
22
|
+
"october",
|
|
23
|
+
"november",
|
|
24
|
+
"december",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** "Last updated on September 22, 2026" as `2026-09-22`; null when the README has no such line. */
|
|
28
|
+
export function vectaraDate(markdown: string): string | null {
|
|
29
|
+
const m = /Last updated on ([A-Za-z]+) (\d{1,2}), (\d{4})/.exec(markdown);
|
|
30
|
+
const month = m ? MONTHS.indexOf((m[1] as string).toLowerCase()) : -1;
|
|
31
|
+
if (!m || month < 0) return null;
|
|
32
|
+
return `${m[3]}-${String(month + 1).padStart(2, "0")}-${(m[2] as string).padStart(2, "0")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cells = (line: string) =>
|
|
36
|
+
line
|
|
37
|
+
.trim()
|
|
38
|
+
.replace(/^\||\|$/g, "")
|
|
39
|
+
.split("|")
|
|
40
|
+
.map((c) => c.trim());
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Two rows per model of the README's table: `factual_consistency` and `hallucination_rate`, in percent. The
|
|
44
|
+
* model keeps its `vendor/` prefix and names no effort.
|
|
45
|
+
*/
|
|
46
|
+
export function parseVectara(markdown: string, fetchedAt: string): SourceRow[] {
|
|
47
|
+
const lines = markdown.split("\n");
|
|
48
|
+
const head = lines.findIndex((l) => /^\|\s*Model\s*\|/i.test(l));
|
|
49
|
+
if (head < 0) return [];
|
|
50
|
+
const columns = cells(lines[head] as string).map((c) => c.toLowerCase());
|
|
51
|
+
const at = (name: string) => columns.findIndex((c) => c.startsWith(name));
|
|
52
|
+
const [consistency, hallucination] = [at("factual consistency"), at("hallucination rate")];
|
|
53
|
+
const date = vectaraDate(markdown) ?? isoDay(fetchedAt);
|
|
54
|
+
const rows: SourceRow[] = [];
|
|
55
|
+
for (const line of lines.slice(head + 2)) {
|
|
56
|
+
if (!line.trim().startsWith("|")) break;
|
|
57
|
+
const c = cells(line);
|
|
58
|
+
const model = c[0];
|
|
59
|
+
if (!model) continue;
|
|
60
|
+
for (const [field, i] of [
|
|
61
|
+
["factual_consistency", consistency],
|
|
62
|
+
["hallucination_rate", hallucination],
|
|
63
|
+
] as const) {
|
|
64
|
+
const value = i < 0 ? null : num((c[i] ?? "").replace("%", ""));
|
|
65
|
+
if (value !== null) rows.push({ rung: model, field, value, date, url: VECTARA_PAGE });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Plan 13 R-F: Epoch ships its tables as one zip. Reading it needs only the central directory and
|
|
2
|
+
// raw deflate (`Bun.inflateSync`), so there is no dependency for it.
|
|
3
|
+
|
|
4
|
+
const EOCD = 0x06054b50;
|
|
5
|
+
const CENTRAL = 0x02014b50;
|
|
6
|
+
const LOCAL = 0x04034b50;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The files of a zip archive whose names `want` accepts, by name: stored (method 0) or deflated (8). Throws
|
|
10
|
+
* on a malformed archive or another method. ZIP64 archives are not read (Epoch's is 2 MB).
|
|
11
|
+
*/
|
|
12
|
+
export function unzip(
|
|
13
|
+
bytes: Uint8Array,
|
|
14
|
+
want: (name: string) => boolean = () => true,
|
|
15
|
+
): Map<string, Uint8Array> {
|
|
16
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
17
|
+
let end = -1;
|
|
18
|
+
// the end record is 22 bytes plus a comment of up to 64 KiB
|
|
19
|
+
for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 0xffff); i--)
|
|
20
|
+
if (dv.getUint32(i, true) === EOCD) {
|
|
21
|
+
end = i;
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
if (end < 0) throw new Error("not a zip archive");
|
|
25
|
+
const count = dv.getUint16(end + 10, true);
|
|
26
|
+
let at = dv.getUint32(end + 16, true);
|
|
27
|
+
const decode = new TextDecoder();
|
|
28
|
+
const out = new Map<string, Uint8Array>();
|
|
29
|
+
for (let k = 0; k < count; k++) {
|
|
30
|
+
if (dv.getUint32(at, true) !== CENTRAL) throw new Error("a malformed zip directory");
|
|
31
|
+
const method = dv.getUint16(at + 10, true);
|
|
32
|
+
const size = dv.getUint32(at + 20, true);
|
|
33
|
+
const nameLen = dv.getUint16(at + 28, true);
|
|
34
|
+
const extraLen = dv.getUint16(at + 30, true);
|
|
35
|
+
const commentLen = dv.getUint16(at + 32, true);
|
|
36
|
+
const local = dv.getUint32(at + 42, true);
|
|
37
|
+
const name = decode.decode(bytes.subarray(at + 46, at + 46 + nameLen));
|
|
38
|
+
at += 46 + nameLen + extraLen + commentLen;
|
|
39
|
+
if (!want(name)) continue;
|
|
40
|
+
if (dv.getUint32(local, true) !== LOCAL) throw new Error(`a malformed zip entry: ${name}`);
|
|
41
|
+
const start = local + 30 + dv.getUint16(local + 26, true) + dv.getUint16(local + 28, true);
|
|
42
|
+
const data = bytes.subarray(start, start + size);
|
|
43
|
+
if (method === 0) out.set(name, data);
|
|
44
|
+
else if (method === 8) out.set(name, Bun.inflateSync(data.slice()));
|
|
45
|
+
else throw new Error(`zip method ${method} is not read: ${name}`);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { barPool, deriveBars } from "../domain/bars.ts";
|
|
4
|
+
import {
|
|
5
|
+
buildCatalog,
|
|
6
|
+
DIMS,
|
|
7
|
+
type Dim,
|
|
8
|
+
type ModelsFile,
|
|
9
|
+
outranks,
|
|
10
|
+
type Score,
|
|
11
|
+
type ScoresFile,
|
|
12
|
+
scoresOf,
|
|
13
|
+
} from "../domain/catalog.ts";
|
|
14
|
+
import { DIFFICULTIES, type Difficulty, KINDS, type Kind } from "../domain/lane.ts";
|
|
15
|
+
import type { SourcesFile } from "../domain/sources.ts";
|
|
16
|
+
import type { SourceTransport } from "../infra/sources/http.ts";
|
|
17
|
+
import { adjacent, derive, type RawAnswers } from "./source-derive.ts";
|
|
18
|
+
import { cachedAnswers, syncSources } from "./source-sync.ts";
|
|
19
|
+
|
|
20
|
+
/** Spec 1.2 §7: a value that moved by more than this is listed in the refresh PR. */
|
|
21
|
+
const MOVED = 0.05;
|
|
22
|
+
|
|
23
|
+
export interface RefreshReport {
|
|
24
|
+
/** the day the data was read, `scores.json`'s new `version` */
|
|
25
|
+
date: string;
|
|
26
|
+
/** canonical rungs with no value in the shipped file before, and at least one now */
|
|
27
|
+
newlyScored: string[];
|
|
28
|
+
/** values that moved by more than 5 %, and values that appeared or went away on a rung already scored */
|
|
29
|
+
moved: { rung: string; dim: Dim; from: number | null; to: number | null }[];
|
|
30
|
+
/** every default threshold that moved, appeared or went away */
|
|
31
|
+
barsMoved: { kind: Kind; difficulty: Difficulty; dim: Dim; from: number | null; to: number | null }[];
|
|
32
|
+
/** shipped treat-likes dropped because their rung now has a value of its own on every dimension they lent */
|
|
33
|
+
standInsDropped: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const key = (s: Pick<Score, "rung" | "dim">) => `${s.rung} ${s.dim}`;
|
|
37
|
+
|
|
38
|
+
/** One value per rung and dimension, the one that outranks the others (spec 1.2 §4.3), in file order. */
|
|
39
|
+
function best(scores: Score[], now: number): Score[] {
|
|
40
|
+
const out = new Map<string, Score>();
|
|
41
|
+
for (const s of scores) {
|
|
42
|
+
const had = out.get(key(s));
|
|
43
|
+
if (!had || outranks(s, had, now)) out.set(key(s), s);
|
|
44
|
+
}
|
|
45
|
+
return [...out.values()].sort(
|
|
46
|
+
(a, b) => a.rung.localeCompare(b.rung) || DIMS.indexOf(a.dim) - DIMS.indexOf(b.dim),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Spec 1.2 §5.2, §7: `catalog/scores.json` rebuilt from the keyless sources' answers. It keeps every hand-typed
|
|
52
|
+
* value (one with no `source`), replaces the keyless values with what `raw` says now (Artificial Analysis is
|
|
53
|
+
* never shipped, whatever `raw` holds), spreads every published value to the family's other efforts as
|
|
54
|
+
* `adjacent` (plan 14 Ruling 3), keeps one value per rung and dimension, derives the default bars with their
|
|
55
|
+
* `barsWhy`, and drops a shipped treat-like its rung no longer needs.
|
|
56
|
+
*/
|
|
57
|
+
export function rebuildShipped(
|
|
58
|
+
raw: RawAnswers,
|
|
59
|
+
ctx: { models: ModelsFile; scores: ScoresFile; sources: SourcesFile; now: number },
|
|
60
|
+
): { file: ScoresFile; report: RefreshReport } {
|
|
61
|
+
const date = new Date(ctx.now).toISOString().slice(0, 10);
|
|
62
|
+
const { "artificial-analysis": _aa, ...keyless } = raw;
|
|
63
|
+
// hand-typed values, less the ones an earlier rebuild spread: those are spread again below
|
|
64
|
+
const hand = ctx.scores.scores.filter((s) => s.source === undefined && s.confidence !== "adjacent");
|
|
65
|
+
const synced = derive(keyless, { ...ctx, scores: { ...ctx.scores, scores: hand } }).scores.filter(
|
|
66
|
+
(s) => s.source !== "artificial-analysis" && s.confidence !== "adjacent",
|
|
67
|
+
);
|
|
68
|
+
const direct = best([...hand, ...synced], ctx.now);
|
|
69
|
+
// an inferred value is catherd's guess for its own rung only: it is never carried to another effort
|
|
70
|
+
const spread = adjacent(
|
|
71
|
+
ctx.models.families,
|
|
72
|
+
direct.filter((s) => s.confidence !== "inferred"),
|
|
73
|
+
new Set(),
|
|
74
|
+
ctx.now,
|
|
75
|
+
);
|
|
76
|
+
const scores = best([...direct, ...spread], ctx.now);
|
|
77
|
+
const benchmark = (d: Dim) => {
|
|
78
|
+
const b = ctx.scores.benchmarks[d];
|
|
79
|
+
return `${b.benchmark} ${b.version}`;
|
|
80
|
+
};
|
|
81
|
+
const { bars, barsWhy } = deriveBars(barPool(scores), benchmark, date);
|
|
82
|
+
|
|
83
|
+
const draft: ScoresFile = { ...ctx.scores, version: date, scores, bars, barsWhy };
|
|
84
|
+
const c = buildCatalog({ models: ctx.models, scores: { ...draft, treatLike: ctx.scores.treatLike } });
|
|
85
|
+
const treatLike: ScoresFile["treatLike"] = {};
|
|
86
|
+
const standInsDropped: string[] = [];
|
|
87
|
+
for (const [rung, t] of Object.entries(ctx.scores.treatLike))
|
|
88
|
+
if (scoresOf(c, rung)?.borrowed.length) treatLike[rung] = t;
|
|
89
|
+
else standInsDropped.push(rung);
|
|
90
|
+
const file: ScoresFile = { ...draft, treatLike };
|
|
91
|
+
return { file, report: { date, ...diffShipped(ctx.scores, file), standInsDropped } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const rungsOf = (f: ScoresFile) => new Set(f.scores.map((s) => s.rung));
|
|
95
|
+
|
|
96
|
+
/** What moved between two shipped files (spec 1.2 §7's PR body). */
|
|
97
|
+
function diffShipped(
|
|
98
|
+
before: ScoresFile,
|
|
99
|
+
after: ScoresFile,
|
|
100
|
+
): Pick<RefreshReport, "newlyScored" | "moved" | "barsMoved"> {
|
|
101
|
+
const had = rungsOf(before);
|
|
102
|
+
const newlyScored = [...rungsOf(after)].filter((r) => !had.has(r)).sort();
|
|
103
|
+
const was = new Map(before.scores.map((s) => [key(s), s.value]));
|
|
104
|
+
const now = new Map(after.scores.map((s) => [key(s), s.value]));
|
|
105
|
+
const moved: RefreshReport["moved"] = [];
|
|
106
|
+
for (const k of [...new Set([...was.keys(), ...now.keys()])].sort()) {
|
|
107
|
+
const [rung, dim] = k.split(" ") as [string, Dim];
|
|
108
|
+
if (newlyScored.includes(rung)) continue;
|
|
109
|
+
const from = was.get(k) ?? null;
|
|
110
|
+
const to = now.get(k) ?? null;
|
|
111
|
+
const far =
|
|
112
|
+
from === null || to === null || (from === 0 ? to !== 0 : Math.abs(to - from) / Math.abs(from) > MOVED);
|
|
113
|
+
if (far) moved.push({ rung, dim, from, to });
|
|
114
|
+
}
|
|
115
|
+
const barsMoved: RefreshReport["barsMoved"] = [];
|
|
116
|
+
for (const kind of KINDS)
|
|
117
|
+
for (const difficulty of DIFFICULTIES)
|
|
118
|
+
for (const dim of DIMS) {
|
|
119
|
+
const from = before.bars[kind]?.[difficulty]?.[dim] ?? null;
|
|
120
|
+
const to = after.bars[kind]?.[difficulty]?.[dim] ?? null;
|
|
121
|
+
if (from !== to) barsMoved.push({ kind, difficulty, dim, from, to });
|
|
122
|
+
}
|
|
123
|
+
return { newlyScored, moved, barsMoved };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Whether the rebuilt file says anything new: its date and `barsWhy` wording alone never count. */
|
|
127
|
+
export function shippedChanged(before: ScoresFile, after: ScoresFile): boolean {
|
|
128
|
+
// as the file reads back: no undefined fields, whatever the key order
|
|
129
|
+
const strip = (f: ScoresFile): unknown => JSON.parse(JSON.stringify({ ...f, version: "", barsWhy: null }));
|
|
130
|
+
return !isDeepStrictEqual(strip(before), strip(after));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const num = (v: number | null) => (v === null ? "none" : String(v));
|
|
134
|
+
|
|
135
|
+
/** Spec 1.2 §7: the refresh PR's body: rungs newly scored, values moved by more than 5 %, bars that moved. */
|
|
136
|
+
export function refreshBody(r: RefreshReport): string {
|
|
137
|
+
const out = [
|
|
138
|
+
`Weekly refresh of the shipped scores from the keyless public sources (data of ${r.date}).`,
|
|
139
|
+
"",
|
|
140
|
+
"Artificial Analysis values are never shipped. Merging this releases a patch through the Release workflow.",
|
|
141
|
+
"",
|
|
142
|
+
`### Rungs newly scored (${r.newlyScored.length})`,
|
|
143
|
+
"",
|
|
144
|
+
...(r.newlyScored.length ? r.newlyScored.map((x) => `- \`${x}\``) : ["none"]),
|
|
145
|
+
"",
|
|
146
|
+
`### Values moved by more than 5 % (${r.moved.length})`,
|
|
147
|
+
"",
|
|
148
|
+
...(r.moved.length
|
|
149
|
+
? ["| rung | dimension | was | now |", "| --- | --- | --- | --- |"].concat(
|
|
150
|
+
r.moved.map((m) => `| \`${m.rung}\` | ${m.dim} | ${num(m.from)} | ${num(m.to)} |`),
|
|
151
|
+
)
|
|
152
|
+
: ["none"]),
|
|
153
|
+
"",
|
|
154
|
+
`### Bars that moved (${r.barsMoved.length})`,
|
|
155
|
+
"",
|
|
156
|
+
...(r.barsMoved.length
|
|
157
|
+
? ["| kind | difficulty | dimension | was | now |", "| --- | --- | --- | --- | --- |"].concat(
|
|
158
|
+
r.barsMoved.map(
|
|
159
|
+
(b) => `| ${b.kind} | ${b.difficulty} | ${b.dim} | ${num(b.from)} | ${num(b.to)} |`,
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
: ["none"]),
|
|
163
|
+
];
|
|
164
|
+
if (r.standInsDropped.length)
|
|
165
|
+
out.push(
|
|
166
|
+
"",
|
|
167
|
+
"### Shipped stand-ins no longer needed",
|
|
168
|
+
"",
|
|
169
|
+
...r.standInsDropped.map((x) => `- \`${x}\` now has values of its own`),
|
|
170
|
+
);
|
|
171
|
+
return `${out.join("\n")}\n`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Spec 1.2 §7, `scripts/catalog-refresh.ts`: syncs every keyless source now (no Artificial Analysis key, the
|
|
176
|
+
* TTL ignored) into the current data folder, rebuilds the shipped file from the answers and, when it says
|
|
177
|
+
* anything new, writes it to `out`. A keyless source that fails writes nothing: a refresh never ships a file
|
|
178
|
+
* that lost a source's values (plan 14 Ruling 11).
|
|
179
|
+
*/
|
|
180
|
+
export async function refreshShipped(o: {
|
|
181
|
+
out: string;
|
|
182
|
+
current: ScoresFile;
|
|
183
|
+
models: ModelsFile;
|
|
184
|
+
sources: SourcesFile;
|
|
185
|
+
transport?: SourceTransport;
|
|
186
|
+
now?: () => number;
|
|
187
|
+
}): Promise<{ changed: boolean; failed: { source: string; error: string }[]; report: RefreshReport | null }> {
|
|
188
|
+
const now = o.now ?? Date.now;
|
|
189
|
+
const sync = await syncSources({ force: true, aaKey: null, transport: o.transport, now });
|
|
190
|
+
if (sync.failed.length) return { changed: false, failed: sync.failed, report: null };
|
|
191
|
+
const { file, report } = rebuildShipped(cachedAnswers(), {
|
|
192
|
+
models: o.models,
|
|
193
|
+
scores: o.current,
|
|
194
|
+
sources: o.sources,
|
|
195
|
+
now: now(),
|
|
196
|
+
});
|
|
197
|
+
const changed = shippedChanged(o.current, file);
|
|
198
|
+
if (changed) writeFileSync(o.out, `${JSON.stringify(file, null, 2)}\n`);
|
|
199
|
+
return { changed, failed: [], report };
|
|
200
|
+
}
|
|
@@ -31,9 +31,13 @@ import { assetPath } from "../infra/assets.ts";
|
|
|
31
31
|
import { withFileLock } from "../infra/filelock.ts";
|
|
32
32
|
import { log } from "../infra/log.ts";
|
|
33
33
|
import { configDir } from "../infra/paths.ts";
|
|
34
|
+
import { readDerived } from "../infra/sources/cache.ts";
|
|
34
35
|
import { ensurePrivateDir, readVersioned, writeJsonAtomic } from "../infra/store.ts";
|
|
35
36
|
import type { CatalogFilter } from "./ports.ts";
|
|
36
37
|
import { listRuns, readAgentRuns, readRecords, readRoutes } from "./run-store.ts";
|
|
38
|
+
import { valuesUsed } from "./provenance.ts";
|
|
39
|
+
import { type EvidenceTable, evidenceLine, evidenceOf, runEvidence } from "./run-evidence.ts";
|
|
40
|
+
import { withStandIns } from "./standins.ts";
|
|
37
41
|
|
|
38
42
|
const DAY_MS = 24 * 3_600_000;
|
|
39
43
|
/** Spec §5.2: `secs_per_task` counts once a rung has this many of the user's own runs. */
|
|
@@ -41,14 +45,14 @@ const MIN_SAMPLES = 5;
|
|
|
41
45
|
|
|
42
46
|
let models: ModelsFile | null = null;
|
|
43
47
|
let scores: ScoresFile | null = null;
|
|
44
|
-
const shippedModels = (): ModelsFile =>
|
|
48
|
+
export const shippedModels = (): ModelsFile =>
|
|
45
49
|
(models ??= readVersioned(assetPath("catalog/models.json"), ModelsFileSchema, 1));
|
|
46
|
-
const shippedScores = (): ScoresFile =>
|
|
50
|
+
export const shippedScores = (): ScoresFile =>
|
|
47
51
|
(scores ??= readVersioned(assetPath("catalog/scores.json"), ScoresFileSchema, 1));
|
|
48
52
|
|
|
49
53
|
export const overridePath = (): string => join(configDir(), "catalog.override.json");
|
|
50
54
|
|
|
51
|
-
function readOverride(): Override {
|
|
55
|
+
export function readOverride(): Override {
|
|
52
56
|
const file = overridePath();
|
|
53
57
|
return existsSync(file) ? readVersioned(file, OverrideSchema, 1) : OverrideSchema.parse({});
|
|
54
58
|
}
|
|
@@ -116,15 +120,24 @@ export function measuredSecs(base: Catalog): Catalog["secs"] {
|
|
|
116
120
|
|
|
117
121
|
/**
|
|
118
122
|
* The shipped catalog with every listing (in `repo` for a backend listed per repository), the user's
|
|
119
|
-
* override and, unless `timings: false`, own
|
|
123
|
+
* override (or `override` in its place: what a change to it would make) and, unless `timings: false`, own
|
|
124
|
+
* timings.
|
|
120
125
|
*/
|
|
121
|
-
export function loadCatalog(o: { timings?: boolean; repo?: string } = {}): Catalog {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
export function loadCatalog(o: { timings?: boolean; repo?: string; override?: Override } = {}): Catalog {
|
|
127
|
+
// spec 1.2 §3.2: whatever the last sync derived; without one (first run, offline), the shipped values alone
|
|
128
|
+
const synced = readDerived();
|
|
129
|
+
// spec 1.2 §6.1: every value a rung lacks on a dimension the bars use comes from its nearest stand-in
|
|
130
|
+
const base = withStandIns(
|
|
131
|
+
buildCatalog({
|
|
132
|
+
models: shippedModels(),
|
|
133
|
+
scores: shippedScores(),
|
|
134
|
+
synced: synced?.scores,
|
|
135
|
+
facts: synced?.facts,
|
|
136
|
+
features: synced?.features,
|
|
137
|
+
override: o.override ?? readOverride(),
|
|
138
|
+
listed: listedModels(o.repo),
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
128
141
|
return o.timings === false ? base : { ...base, secs: measuredSecs(base) };
|
|
129
142
|
}
|
|
130
143
|
|
|
@@ -250,7 +263,9 @@ export async function saveTreatLike(rung: string, like: string): Promise<{ rung:
|
|
|
250
263
|
throw new CatherdError("E_CONFIG_INVALID", `${rung} cannot be treated like itself`, {
|
|
251
264
|
fix: "name a different, scored rung",
|
|
252
265
|
});
|
|
253
|
-
|
|
266
|
+
// a rung a sync scored on some dimensions may still borrow the others; one with every value `to` lends may not
|
|
267
|
+
const own = c.scores[from] ?? {};
|
|
268
|
+
if (DIMS.filter((d) => c.scores[to]?.[d]).every((d) => own[d]))
|
|
254
269
|
throw new CatherdError(
|
|
255
270
|
"E_CONFIG_INVALID",
|
|
256
271
|
`${from} has scores of its own; a treat-like would not change it`,
|
|
@@ -276,6 +291,30 @@ export async function saveTreatLike(rung: string, like: string): Promise<{ rung:
|
|
|
276
291
|
return { rung: from, like: to };
|
|
277
292
|
}
|
|
278
293
|
|
|
294
|
+
/** A rung given as `backend:model#effort` or `model#effort`, as the canonical rung the override keys it by. */
|
|
295
|
+
export const canonicalRung = (rung: string): string => canonicalOf(loadCatalog({ timings: false }), rung);
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Spec 1.2 §6.4 `treat-like --clear|--reset`: removes the user's treat-likes for `rungs` (canonical), or every
|
|
299
|
+
* one with `"all"`, from catalog.override.json; the shipped ones are not the user's to remove. Returns the
|
|
300
|
+
* mappings removed. Leaves the override's other fields as they are.
|
|
301
|
+
*/
|
|
302
|
+
export async function removeTreatLikes(rungs: string[] | "all"): Promise<[string, string][]> {
|
|
303
|
+
if (!existsSync(overridePath())) return [];
|
|
304
|
+
let removed: [string, string][] = [];
|
|
305
|
+
await withFileLock(overridePath(), () => {
|
|
306
|
+
const cur = readOverride();
|
|
307
|
+
const gone = Object.entries(cur.treatLike).filter(([r]) => rungs === "all" || rungs.includes(r));
|
|
308
|
+
removed = gone;
|
|
309
|
+
if (gone.length === 0) return;
|
|
310
|
+
const treatLike = Object.fromEntries(
|
|
311
|
+
Object.entries(cur.treatLike).filter(([r]) => !gone.some(([g]) => g === r)),
|
|
312
|
+
);
|
|
313
|
+
writeJsonAtomic(overridePath(), { ...cur, schema: 1, treatLike });
|
|
314
|
+
});
|
|
315
|
+
return removed;
|
|
316
|
+
}
|
|
317
|
+
|
|
279
318
|
/** The backend a billing or harness key bills or isolates: `opencode-go` is a provider of the opencode backend. */
|
|
280
319
|
export const backendOfKey = (key: string) => (key === "opencode-go" ? "opencode" : key);
|
|
281
320
|
|
|
@@ -285,21 +324,34 @@ function rungRows(
|
|
|
285
324
|
model: string,
|
|
286
325
|
efforts: string[],
|
|
287
326
|
billing: Partial<Record<string, BillingMode>>,
|
|
327
|
+
evidence: EvidenceTable,
|
|
288
328
|
) {
|
|
289
329
|
return (efforts.length ? efforts : ["default"]).map((effort) => {
|
|
290
330
|
const rung = `${backend}:${model}#${effort}`;
|
|
291
331
|
const info = rungInfo(c, rung);
|
|
292
332
|
const s = scoresOf(c, info.canonical);
|
|
293
|
-
const scored: Record<
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
333
|
+
const scored: Record<
|
|
334
|
+
string,
|
|
335
|
+
{
|
|
336
|
+
value: number;
|
|
337
|
+
benchmark: string;
|
|
338
|
+
confidence: string;
|
|
339
|
+
source: string;
|
|
340
|
+
date: string;
|
|
341
|
+
/** spec 1.2 §5.3: the rung a borrowed or inferred value belongs to */
|
|
342
|
+
from?: string;
|
|
343
|
+
}
|
|
344
|
+
> = {};
|
|
345
|
+
for (const v of valuesUsed(c, info.canonical))
|
|
346
|
+
scored[v.dim] = {
|
|
347
|
+
value: v.value,
|
|
348
|
+
benchmark: v.benchmark,
|
|
349
|
+
// a value lent by a treat-like or a stand-in is catherd's guess for this rung, whatever its own confidence
|
|
350
|
+
confidence: v.inferred ? "inferred" : v.confidence,
|
|
351
|
+
source: v.source,
|
|
352
|
+
date: v.date,
|
|
353
|
+
...(v.from ? { from: v.from } : {}),
|
|
354
|
+
};
|
|
303
355
|
const like = c.treatLike[info.canonical] ?? null;
|
|
304
356
|
return {
|
|
305
357
|
rung,
|
|
@@ -308,6 +360,8 @@ function rungRows(
|
|
|
308
360
|
scores: scored,
|
|
309
361
|
treatLike: s?.via ? like : null,
|
|
310
362
|
cost: costOf(info.family, effort, billing[info.key] ?? DEFAULT_BILLING[info.key]),
|
|
363
|
+
/** spec 1.2 §8: catherd's own runs on it, over every kind; never used for routing */
|
|
364
|
+
evidence: evidenceLine(evidenceOf(evidence, info.canonical)),
|
|
311
365
|
};
|
|
312
366
|
});
|
|
313
367
|
}
|
|
@@ -324,6 +378,9 @@ export interface CatalogModel {
|
|
|
324
378
|
roles: Role[];
|
|
325
379
|
listed: boolean | null;
|
|
326
380
|
notes: Record<string, string>;
|
|
381
|
+
/** spec 1.2 §4.1: the family's API price, dollars per million tokens, and its speed facts */
|
|
382
|
+
price: Family["price"] | null;
|
|
383
|
+
speed: Record<string, number>;
|
|
327
384
|
rungs: ReturnType<typeof rungRows>;
|
|
328
385
|
}
|
|
329
386
|
|
|
@@ -336,6 +393,7 @@ export function catalogQuery(
|
|
|
336
393
|
billing: Partial<Record<string, BillingMode>> = {},
|
|
337
394
|
): { total: number; models: CatalogModel[] } {
|
|
338
395
|
const c = loadCatalog({ timings: false, repo: f.repo });
|
|
396
|
+
const evidence = runEvidence(c);
|
|
339
397
|
const rows: CatalogModel[] = [];
|
|
340
398
|
const known = new Set<string>();
|
|
341
399
|
const entry = (backend: string, model: string, fam: Family | null): CatalogModel => {
|
|
@@ -352,7 +410,9 @@ export function catalogQuery(
|
|
|
352
410
|
roles: ROLES.filter((r) => capableFor(c, probe, r)),
|
|
353
411
|
listed: probe.listed,
|
|
354
412
|
notes: fam?.notes ?? {},
|
|
355
|
-
|
|
413
|
+
price: fam?.price ?? null,
|
|
414
|
+
speed: fam?.speed ?? {},
|
|
415
|
+
rungs: rungRows(c, backend, model, probe.efforts, billing, evidence),
|
|
356
416
|
};
|
|
357
417
|
};
|
|
358
418
|
for (const fam of c.families)
|