File size: 12,393 Bytes
da8db3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
import { describe, expect, it } from "vitest"

import { getCanonicalModelIdentity, getModelFamilyRouteId } from "../../lib/model-family"

// Executable spec for `notes/transformations/01-identity-canonicalization.md`.
//
// Each table here corresponds to a Group in the spec. Pipeline-side
// implementation must produce identical outputs for every row. Adding a row
// here is the way to extend the spec.
//
// When the pipeline ships matching values, run `scripts/verify-identity.mjs`
// against the full live cache to confirm cross-corpus equivalence before
// deleting the TS implementation (task #5b in the migration tasklist).

interface Case {
  id: string
  name?: string
  developer?: string
  expected: {
    namespace?: string
    familySlug?: string
    familyId?: string
    familyName?: string
    variantKey?: string
    variantLabel?: string
    versionDate?: string | undefined
    versionQualifier?: string | undefined
    variantDisplayName?: string
  }
  why: string
}

// ---------------------------------------------------------------------------
// Group A β€” Token case map
// ---------------------------------------------------------------------------
//
// Each token in the map below must title-case to the canonical form. The full
// case map is captured in the spec doc (Group A table). Here we exercise a
// representative subset plus the "no map entry" baseline.

describe("Group A β€” token case map", () => {
  const cases: Case[] = [
    { id: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", expected: { familyName: "Claude Opus 4.5" }, why: "claude β†’ Claude, opus β†’ Opus" },
    { id: "openai/gpt-5", name: "GPT 5", expected: { familyName: "GPT 5" }, why: "gpt β†’ GPT (uppercased)" },
    { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro", expected: { familyName: "Gemini 2.5 Pro" }, why: "gemini, pro" },
    { id: "meta/llama-3", name: "Llama 3", expected: { familyName: "Llama 3" }, why: "llama" },
    { id: "01-ai/yi-large", name: "Yi Large", expected: { familyName: "Yi Large" }, why: "yi (lowercase letter, but in map)" },
    { id: "anthropic/claude-haiku", name: "Claude Haiku", expected: { familyName: "Claude Haiku" }, why: "haiku" },
    { id: "anthropic/claude-instant", name: "Claude Instant", expected: { familyName: "Claude Instant" }, why: "instant" },
    { id: "x/x-ai", name: "x AI", expected: { familyName: "X AI" }, why: "ai β†’ AI (a deliberate capitalization)" },
    { id: "test/turbo-mini", name: "Turbo Mini", expected: { familyName: "Turbo Mini" }, why: "turbo, mini" },
    { id: "qwen/qwen-coder", name: "Qwen Coder", expected: { familyName: "Qwen Coder" }, why: "qwen, coder" },
    { id: "test/foo-bar", name: "Foo Bar", expected: { familyName: "Foo Bar" }, why: "tokens not in map β†’ naive title-case" },
  ]
  it.each(cases)("$why β†’ familyName=$expected.familyName", ({ id, name, expected }) => {
    const result = getCanonicalModelIdentity({ id, name: name ?? "" })
    expect(result.familyName).toBe(expected.familyName)
  })
})

// ---------------------------------------------------------------------------
// Group B β€” v/V version-token rule
// ---------------------------------------------------------------------------
//
// Tokens matching /^v\d/i are lowercased. This rule is the source of the
// 1,253-card pipeline disagreement documented in the spec; pipeline must
// apply this rule before emitting model_family_name.

describe("Group B β€” v/V version-token rule", () => {
  const cases: Case[] = [
    { id: "deepseek-ai/deepseek-v3", name: "Deepseek V3", expected: { familyName: "Deepseek v3" }, why: "trailing v3" },
    { id: "deepseek-ai/deepseek-v3.1", name: "Deepseek V3.1", expected: { familyName: "Deepseek v3.1" }, why: "v3.1 with patch" },
    { id: "mistralai/mistral-7b-instruct-v0.3", name: "Mistral 7B Instruct v0.3", expected: { familyName: "Mistral 7B Instruct v0.3" }, why: "v0.3 in middle, mistral & instruct in case map" },
    { id: "mistralai/mixtral-8x22b-instruct-v0.1", name: "Mixtral 8x22b Instruct v0.1", expected: { familyName: "Mixtral 8x22b Instruct v0.1" }, why: "v0.1 with size token" },
    { id: "amazon/nova-lite-v1.0", name: "Nova Lite v1.0", expected: { familyName: "Nova Lite v1.0" }, why: "v1.0" },
    { id: "test/foo-bar", name: "Foo Bar", expected: { familyName: "Foo Bar" }, why: "no v rule applies" },
  ]
  it.each(cases)("$why β†’ familyName=$expected.familyName", ({ id, name, expected }) => {
    expect(getCanonicalModelIdentity({ id, name: name ?? "" }).familyName).toBe(expected.familyName)
  })
})

// ---------------------------------------------------------------------------
// Group C β€” Date and qualifier extraction
// ---------------------------------------------------------------------------

describe("Group C β€” date and qualifier extraction", () => {
  const cases: Case[] = [
    {
      id: "anthropic/claude-3-5-sonnet",
      name: "Claude 3.5 Sonnet",
      expected: { familySlug: "claude-3.5-sonnet", versionDate: undefined, versionQualifier: undefined, variantKey: "base", variantLabel: "Current" },
      why: "no date in handle β†’ base variant, Current label",
    },
    {
      id: "anthropic/claude-3-5-sonnet-20240620",
      name: "Claude 3.5 Sonnet 20240620",
      expected: { familySlug: "claude-3.5-sonnet", versionDate: "2024-06-20", versionQualifier: undefined, variantKey: "20240620", variantLabel: "2024-06-20" },
      why: "YYYYMMDD date present, no qualifier",
    },
    {
      id: "anthropic/claude-3-5-sonnet-20240620-thinking",
      name: "Claude 3.5 Sonnet 20240620 Thinking",
      expected: {
        familySlug: "claude-3.5-sonnet",
        versionDate: "2024-06-20",
        versionQualifier: "Thinking",
        variantKey: "20240620-thinking",
        variantLabel: "2024-06-20 Β· Thinking",
      },
      why: "date plus single-token qualifier",
    },
    {
      id: "openai/gpt-5-2025-12-11-thinking-high",
      name: "GPT 5 2025-12-11 thinking high",
      expected: {
        // The (?:19|20)\d{6} regex requires 8 contiguous digits; "2025-12-11" has dashes, so
        // it doesn't match as a date. NormalizeHandle's digit-dash-digit collapse only fires when
        // the right side is followed by a dash or end, so "2025-12-11" stays as is.
        // β†’ no date matched β†’ base variant.
        variantKey: "base",
        versionDate: undefined,
      },
      why: "dashed date is NOT recognized β€” only YYYYMMDD form",
    },
  ]
  it.each(cases)("$why", ({ id, name, expected }) => {
    const result = getCanonicalModelIdentity({ id, name: name ?? "" })
    if (expected.familySlug !== undefined) expect(result.familySlug).toBe(expected.familySlug)
    if (expected.versionDate !== undefined || "versionDate" in expected) expect(result.versionDate).toBe(expected.versionDate)
    if (expected.versionQualifier !== undefined || "versionQualifier" in expected) expect(result.versionQualifier).toBe(expected.versionQualifier)
    if (expected.variantKey !== undefined) expect(result.variantKey).toBe(expected.variantKey)
    if (expected.variantLabel !== undefined) expect(result.variantLabel).toBe(expected.variantLabel)
  })
})

// ---------------------------------------------------------------------------
// Group D β€” Handle normalization
// ---------------------------------------------------------------------------

describe("Group D β€” handle normalization (familyId derivation)", () => {
  const cases: Case[] = [
    { id: "anthropic/Claude_Opus_4.5", name: "Claude Opus 4.5", expected: { familyId: "anthropic/claude-opus-4.5" }, why: "underscore β†’ dash, lowercase" },
    { id: "anthropic/claude opus 4.5", name: "Claude Opus 4.5", expected: { familyId: "anthropic/claude-opus-4.5" }, why: "space β†’ dash" },
    { id: "anthropic/--claude--opus--", name: "Claude Opus", expected: { familyId: "anthropic/claude-opus" }, why: "leading/trailing/repeated dashes collapsed" },
    { id: "anthropic/claude-3-5-sonnet", name: "Claude 3.5 Sonnet", expected: { familyId: "anthropic/claude-3.5-sonnet" }, why: "digit-dash-digit-dash collapses to digit-dot-digit-dash" },
    { id: "anthropic/claude-3-5", name: "Claude 3.5", expected: { familyId: "anthropic/claude-3.5" }, why: "digit-dash-digit at end collapses to digit-dot-digit" },
    { id: "google/gemini-1.5-pro", name: "Gemini 1.5 Pro", expected: { familyId: "google/gemini-1.5-pro" }, why: "existing dot is preserved" },
  ]
  it.each(cases)("$why", ({ id, name, expected }) => {
    expect(getCanonicalModelIdentity({ id, name: name ?? "" }).familyId).toBe(expected.familyId)
  })
})

// ---------------------------------------------------------------------------
// Group E β€” Namespace and rawHandle extraction
// ---------------------------------------------------------------------------

describe("Group E β€” namespace + rawHandle extraction", () => {
  it("id with slash β†’ first segment is namespace, rest is handle", () => {
    const r = getCanonicalModelIdentity({ id: "anthropic/claude-opus-4-5", name: "Claude Opus 4.5" })
    expect(r.namespace).toBe("anthropic")
    expect(r.rawHandle).toBe("claude-opus-4-5")
  })
  it("id without slash falls back to developer field for namespace", () => {
    const r = getCanonicalModelIdentity({ id: "gpt-5", name: "GPT 5", developer: "OpenAI" })
    expect(r.namespace).toBe("openai")
  })
  it("developer with spaces is slug-cased for namespace", () => {
    const r = getCanonicalModelIdentity({ id: "claude-opus-4.5", name: "Claude Opus 4.5", developer: "Anthropic AI" })
    expect(r.namespace).toBe("anthropic-ai")
  })
  it("namespace prefix in name is stripped when computing rawHandle from name", () => {
    // Falls into getRawHandle's strippedName path when id has no '/' segment
    const r = getCanonicalModelIdentity({ id: "", name: "anthropic/Claude Opus 4.5", developer: "Anthropic" })
    expect(r.namespace).toBe("anthropic")
    // Note: with id empty, idHandle is empty, so falls back to stripNamespace(name, namespace)
    expect(r.rawHandle).toBe("Claude Opus 4.5")
  })
})

// ---------------------------------------------------------------------------
// Group F β€” familyId and model_route_id contract
// ---------------------------------------------------------------------------

describe("Group F β€” familyId and model_route_id", () => {
  const cases = [
    { familyId: "anthropic/claude-opus-4.5", routeId: "anthropic__claude-opus-4.5" },
    { familyId: "openai/gpt-5", routeId: "openai__gpt-5" },
    { familyId: "01-ai/yi-large", routeId: "01-ai__yi-large" },
    { familyId: "google/gemini-2.5-pro", routeId: "google__gemini-2.5-pro" },
  ]
  it.each(cases)("getModelFamilyRouteId($familyId) === $routeId", ({ familyId, routeId }) => {
    expect(getModelFamilyRouteId(familyId)).toBe(routeId)
  })
})

// ---------------------------------------------------------------------------
// Integration cases β€” full-tuple round-trips for fixtures we ship
// ---------------------------------------------------------------------------
//
// These mirror real model cards in tests/fixtures/model-cards/ (where the
// pipeline ALSO emits the canonical fields). When pipeline matches across the
// full corpus, this section is a sanity check that the spec stays in sync
// with the fixtures.

describe("integration β€” fixtures round-trip", () => {
  const fixtures = [
    {
      id: "anthropic/claude-opus-4.5",
      name: "Claude Opus 4.5",
      expected: {
        familyId: "anthropic/claude-opus-4.5",
        familyName: "Claude Opus 4.5",
        variantKey: "base",
      },
      why: "anthropic__claude-opus-4.5 fixture (dotted route_id quirk)",
    },
    {
      id: "openai/gpt-5",
      name: "GPT 5",
      expected: {
        familyId: "openai/gpt-5",
        familyName: "GPT 5",
        variantKey: "base",
      },
      why: "openai__gpt-5 fixture",
    },
    {
      id: "01-ai/yi-34b",
      name: "Yi 34B",
      expected: {
        familyId: "01-ai/yi-34b",
        familyName: "Yi 34B",
        variantKey: "base",
      },
      why: "01-ai__yi-34b fixture (dash-prefix slug)",
    },
  ]
  it.each(fixtures)("$why", ({ id, name, expected }) => {
    const result = getCanonicalModelIdentity({ id, name })
    expect(result.familyId).toBe(expected.familyId)
    expect(result.familyName).toBe(expected.familyName)
    expect(result.variantKey).toBe(expected.variantKey)
  })
})