diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1750075..03b22ac 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -16,6 +16,7 @@ import type { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PROVIDER_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; type RunApiRepository = Pick; type ControlQueue = Pick; @@ -29,6 +30,7 @@ type ArchiveRepository = Pick< | "listModels" | "listTests" | "listRuns" + | "listCatalogChanges" | "getRun" | "getModel" | "getTest" @@ -164,6 +166,39 @@ export function createApp(deps: AppDependencies) { return c.json({ tests: await deps.archive.listTests() }); }); + app.get("/v1/archive/changes", async (c) => { + if (!deps.archive) { + return c.json({ error: "archive_unavailable" }, 503); + } + + const modelId = c.req.query("modelId"); + const provider = c.req.query("provider"); + const rawLimit = c.req.query("limit"); + + if (modelId && !UUID_RE.test(modelId)) { + return c.json({ error: "invalid_model_id" }, 400); + } + if (provider && !PROVIDER_SLUG_RE.test(provider)) { + return c.json({ error: "invalid_provider" }, 400); + } + + const limit = rawLimit === undefined ? undefined : Number(rawLimit); + if ( + limit !== undefined && + (!Number.isInteger(limit) || limit < 1 || limit > 100) + ) { + return c.json({ error: "invalid_limit" }, 400); + } + + return c.json({ + changes: await deps.archive.listCatalogChanges({ + ...(modelId ? { modelId } : {}), + ...(provider ? { providerSlug: provider } : {}), + ...(limit !== undefined ? { limit } : {}), + }), + }); + }); + app.get("/v1/archive/models/:modelId", async (c) => { if (!deps.archive) { return c.json({ error: "archive_unavailable" }, 503); diff --git a/apps/api/test/archive-api.test.ts b/apps/api/test/archive-api.test.ts index 544874e..f6bf5bf 100644 --- a/apps/api/test/archive-api.test.ts +++ b/apps/api/test/archive-api.test.ts @@ -22,6 +22,49 @@ const archiveSource = { contentSha256: null, } as const; +const archiveCatalogChange = { + id: `alias:${ALIAS_EVENT_ID}`, + changeType: "alias_target_changed", + occurredAt: "2026-09-02T00:00:00.000Z", + provider: { + id: "00000000-0000-4000-8000-000000000024", + slug: "deepseek", + name: "DeepSeek", + }, + alias: { + id: ALIAS_ID, + value: "deepseek-flash", + }, + changedFields: ["snapshot"], + previous: { + model: { + id: MODEL_ID, + canonicalSlug: "deepseek-flash", + marketingName: "DeepSeek Flash", + }, + snapshot: null, + endpoint: null, + apiModelId: null, + }, + current: { + model: { + id: MODEL_ID, + canonicalSlug: "deepseek-flash", + marketingName: "DeepSeek Flash", + }, + snapshot: { + id: "00000000-0000-4000-8000-000000000034", + providerSnapshotId: "deepseek-flash-202609", + }, + endpoint: null, + apiModelId: null, + }, + previousRecordId: "00000000-0000-4000-8000-000000000042", + currentRecordId: ALIAS_EVENT_ID, + previousSource: archiveSource, + currentSource: archiveSource, +} as const; + function baseRuns() { return { @@ -259,6 +302,7 @@ const archiveModelDetail = { snapshotId: null, }, ], + identityDrift: [archiveCatalogChange], testCoverage: [ { testCaseId: TEST_CASE_ID, @@ -444,6 +488,7 @@ describe("Archive read API", () => { }, ], listRuns: async () => [archiveRun], + listCatalogChanges: async () => [archiveCatalogChange], getRun: async (runId) => (runId === RUN_ID ? archiveRun : null), getModel: async (modelId) => modelId === MODEL_ID ? archiveModelDetail : null, @@ -471,6 +516,22 @@ describe("Archive read API", () => { ], }); + const changes = await app.request( + `/v1/archive/changes?modelId=${MODEL_ID}&provider=deepseek&limit=10`, + ); + expect(changes.status).toBe(200); + await expect(changes.json()).resolves.toMatchObject({ + changes: [ + { + changeType: "alias_target_changed", + changedFields: ["snapshot"], + current: { + snapshot: { providerSnapshotId: "deepseek-flash-202609" }, + }, + }, + ], + }); + const model = await app.request("/v1/archive/models/" + MODEL_ID); expect(model.status).toBe(200); await expect(model.json()).resolves.toMatchObject({ @@ -530,6 +591,7 @@ describe("Archive read API", () => { listModels: async () => [], listTests: async () => [], listRuns: async () => [], + listCatalogChanges: async () => [], getRun: async () => null, getModel: async () => null, getTest: async () => null, @@ -544,6 +606,15 @@ describe("Archive read API", () => { expect( (await app.request("/v1/archive/runs?limit=101")).status, ).toBe(400); + expect( + (await app.request("/v1/archive/changes?modelId=nope")).status, + ).toBe(400); + expect( + (await app.request("/v1/archive/changes?provider=Bad.Provider")).status, + ).toBe(400); + expect( + (await app.request("/v1/archive/changes?limit=101")).status, + ).toBe(400); expect( (await app.request("/v1/archive/models/nope")).status, ).toBe(400); diff --git a/apps/web/src/modelapse.ts b/apps/web/src/modelapse.ts index 79cfc58..7bf8c4e 100644 --- a/apps/web/src/modelapse.ts +++ b/apps/web/src/modelapse.ts @@ -82,6 +82,47 @@ export interface ArchiveSource { readonly contentSha256: string | null; } +export interface ArchiveCatalogIdentityState { + readonly model: { + readonly id: string; + readonly canonicalSlug: string; + readonly marketingName: string; + } | null; + readonly snapshot: { + readonly id: string; + readonly providerSnapshotId: string; + } | null; + readonly endpoint: { + readonly id: string; + readonly path: string; + readonly baseUrl: string; + readonly hostname: string; + } | null; + readonly apiModelId: string | null; +} + +export interface ArchiveCatalogChange { + readonly id: string; + readonly changeType: "alias_target_changed" | "execution_binding_changed"; + readonly occurredAt: string; + readonly provider: { + readonly id: string; + readonly slug: string; + readonly name: string; + }; + readonly alias: { + readonly id: string; + readonly value: string; + } | null; + readonly changedFields: readonly string[]; + readonly previous: ArchiveCatalogIdentityState; + readonly current: ArchiveCatalogIdentityState; + readonly previousRecordId: string; + readonly currentRecordId: string; + readonly previousSource: ArchiveSource | null; + readonly currentSource: ArchiveSource; +} + export interface ArchiveModelAliasResolution { readonly id: string; readonly alias: { @@ -220,6 +261,7 @@ export interface ArchiveModelDetail extends ArchiveModel { readonly aliasResolutions: readonly ArchiveModelAliasResolution[]; readonly executionBindings: readonly ArchiveModelExecutionBinding[]; readonly identityTimeline: readonly ArchiveIdentityTimelineEvent[]; + readonly identityDrift: readonly ArchiveCatalogChange[]; readonly testCoverage: readonly { readonly testCaseId: string; readonly familySlug: string; @@ -786,6 +828,15 @@ export const getArchiveRun = createServerFn({ method: "POST" }) } }); +export const getArchiveCatalogChanges = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const result = await requestJson<{ changes: readonly ArchiveCatalogChange[] }>( + "/v1/archive/changes?limit=100", + ); + return result.changes; + }, +); + export const getArchiveCatalog = createServerFn({ method: "GET" }).handler( async (): Promise => { const [models, tests] = await Promise.all([ diff --git a/apps/web/src/routes/changes.tsx b/apps/web/src/routes/changes.tsx new file mode 100644 index 0000000..ece59b8 --- /dev/null +++ b/apps/web/src/routes/changes.tsx @@ -0,0 +1,252 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { + getArchiveCatalog, + getArchiveCatalogChanges, + type ArchiveCatalogChange, + type ArchiveCatalogIdentityState, +} from "../modelapse"; + +export const Route = createFileRoute("/changes")({ + loader: async () => { + const [changes, catalog] = await Promise.all([ + getArchiveCatalogChanges(), + getArchiveCatalog(), + ]); + return { changes, catalog }; + }, + component: CatalogChangesPage, +}); + +function formatTimestamp(value: string): string { + return new Date(value).toISOString().replace("T", " ").replace(".000Z", "Z"); +} + +function safeSourceHref(value: string | null): string | null { + if (!value) return null; + try { + const parsed = new URL(value); + return parsed.protocol === "https:" || parsed.protocol === "http:" + ? parsed.toString() + : null; + } catch { + return null; + } +} + +function stateLabel(state: ArchiveCatalogIdentityState): string { + const pieces = [ + state.model?.marketingName ?? state.model?.canonicalSlug ?? null, + state.apiModelId, + state.snapshot?.providerSnapshotId ?? null, + state.endpoint?.hostname ?? null, + ].filter((value): value is string => Boolean(value)); + return pieces.length ? pieces.join(" · ") : "—"; +} + +function changeSubject(change: ArchiveCatalogChange): string { + if (change.alias) return `alias ${change.alias.value}`; + return ( + change.current.model?.canonicalSlug ?? + change.previous.model?.canonicalSlug ?? + "execution binding" + ); +} + +function CatalogChangesPage() { + const { changes, catalog } = Route.useLoaderData(); + const [provider, setProvider] = useState(""); + const [modelId, setModelId] = useState(""); + const [kind, setKind] = useState(""); + + const providers = useMemo( + () => + [...new Map( + catalog.models.map((model) => [model.provider.slug, model.provider]), + ).values()].sort((a, b) => a.slug.localeCompare(b.slug)), + [catalog.models], + ); + + const filtered = useMemo( + () => + changes.filter((change) => { + if (provider && change.provider.slug !== provider) return false; + if ( + modelId && + change.previous.model?.id !== modelId && + change.current.model?.id !== modelId + ) { + return false; + } + if (kind && change.changeType !== kind) return false; + return true; + }), + [changes, provider, modelId, kind], + ); + + return ( +
+
+ + M + + Modelapse + AI Model Test & Evolution Archive + + + +
+ +
+
+ Archive + / + Catalog changes +
+
+
+

CATALOG CHANGE DETECTION

+

Identity drift

+

+ Derived transitions between chronological, source-backed catalog facts. +

+
+
+ {changes.length} recent change(s) +
+
+
+ +
+
+
+

FILTERS

+

Inspect the change feed

+
+

+ Drift is derived from immutable alias observations and closed execution-binding + intervals. It is not a new canonical identity claim. +

+
+ +
+ + + +
+ +
+ {filtered.map((change) => { + const sourceHref = safeSourceHref(change.currentSource.url); + const model = + change.current.model ?? + change.previous.model; + return ( +
+
+ {change.changeType.replaceAll("_", " ")} + {changeSubject(change)} + {formatTimestamp(change.occurredAt)} + {change.provider.name} +
+ {change.changedFields.map((field) => ( + {field.replaceAll("_", " ")} + ))} +
+
+ +
+ Before + {stateLabel(change.previous)} + {change.previous.model ? ( + + {change.previous.model.canonicalSlug} → + + ) : null} +
+ + + +
+ After + {stateLabel(change.current)} + {model ? ( + + {model.canonicalSlug} → + + ) : null} +
+ +
+ Current source + + {change.currentSource.title ?? + change.currentSource.url ?? + change.currentSource.sourceType} + + + retrieved {formatTimestamp(change.currentSource.retrievedAt)} + + {sourceHref ? ( + + Source ↗ + + ) : null} +
+
+ ); + })} + + {filtered.length === 0 ? ( +
+ No source-backed identity drift matches these filters. +
+ ) : null} +
+
+ +
+ Modelapse + identity drift = derived change, not rewritten history +
+
+ ); +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index c1fe30d..cd6b169 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -393,9 +393,14 @@ function ModelapseHome() {

Recent sealed runs

- - Compare models → - +