model-compare-opencode-plugin 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "model-compare-opencode-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "OpenCode plugin that switches OpenRouter model aliases to the best value-for-money pick from canonical/model-compare's ranking (balanced, price, quality).",
5
+ "keywords": [
6
+ "opencode",
7
+ "openrouter",
8
+ "model-compare",
9
+ "plugin",
10
+ "llm"
11
+ ],
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./tui": "./dist/tui.js"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "dist/tui.js",
20
+ "vendor/model_compare.py",
21
+ "vendor/UPSTREAM.json",
22
+ "LICENSE",
23
+ "README.md"
24
+ ],
25
+ "license": "GPL-3.0-only",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/canonical/model-compare-opencode-plugin.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/canonical/model-compare-opencode-plugin/issues"
32
+ },
33
+ "homepage": "https://github.com/canonical/model-compare-opencode-plugin#readme",
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "overrides": {
38
+ "seroval": "^1.6.9"
39
+ },
40
+ "scripts": {
41
+ "build": "node scripts/build-tui.mjs",
42
+ "test": "vitest run",
43
+ "typecheck": "tsc --noEmit",
44
+ "prepublishOnly": "npm run build && npm run typecheck && npm test && node scripts/pack-smoke.mjs"
45
+ },
46
+ "dependencies": {
47
+ "@opencode/plugin": "2.0.16"
48
+ },
49
+ "peerDependencies": {
50
+ "@opentui/core": "^0.5.8",
51
+ "@opentui/solid": "^0.5.8",
52
+ "solid-js": "^1.9.0"
53
+ },
54
+ "devDependencies": {
55
+ "@opentui/core": "0.5.17",
56
+ "@opentui/solid": "0.5.17",
57
+ "@types/node": "^22.18.13",
58
+ "solid-js": "1.9.12",
59
+ "typescript": "^7.0.2",
60
+ "vitest": "^4.1.11"
61
+ }
62
+ }
package/src/config.ts ADDED
@@ -0,0 +1,197 @@
1
+ // src/config.ts
2
+ // Plugin option parsing (design §3): warn-and-default validation over the
3
+ // raw `options` object OpenCode hands the plugin. Any violation logs a
4
+ // warning naming the option and applies the default (or drops the offending
5
+ // piece); absent options apply their default silently. Never throws —
6
+ // whatever shape arrives collapses to a usable Config.
7
+ //
8
+ // Absent-vs-invalid: an option OpenCode didn't set (`undefined`) simply takes
9
+ // its default without a warning — nothing was misconfigured, and a bare
10
+ // `plugins` entry without `options` must stay quiet. A value that IS present
11
+ // but invalid warns. `fallback` and `scriptPath` additionally accept explicit
12
+ // `null` as a valid "unset" (their §3 rows list `null` as legal).
13
+
14
+ import type { Priority, Weights } from "./core/types"
15
+
16
+ export type Config = {
17
+ source: "auto" | "local" | "remote"
18
+ defaultPriority: Priority
19
+ // "openrouter/<id>" (§3) or null.
20
+ fallback: string | null
21
+ weights: Partial<Record<Priority, Partial<Weights>>>
22
+ pinDays: number // default 14
23
+ minLead: number // default 0.03
24
+ catalogURL: string // default "https://canonical.github.io/model-compare/catalog.json"
25
+ scriptPath: string | null // null → vendored copy
26
+ }
27
+
28
+ const SOURCES = ["auto", "local", "remote"] as const
29
+ const PRIORITIES = ["balanced", "price", "quality"] as const
30
+ const WEIGHT_KEYS = ["quality", "price", "context", "age"] as const
31
+
32
+ const DEFAULT_CATALOG_URL = "https://canonical.github.io/model-compare/catalog.json"
33
+ const FALLBACK_PREFIX = "openrouter/"
34
+
35
+ // True for plain config objects. Arrays are excluded: their index keys are
36
+ // never meaningful option names, so they warn like any other garbage.
37
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
38
+ return typeof v === "object" && v !== null && !Array.isArray(v)
39
+ }
40
+
41
+ // A safe rendering of an offending value for warning messages. Deliberately
42
+ // avoids JSON.stringify, which throws on circular structures.
43
+ function shown(v: unknown): string {
44
+ if (typeof v === "string") return `"${v}"`
45
+ if (typeof v === "object" && v !== null) return Array.isArray(v) ? "an array" : "an object"
46
+ if (typeof v === "function") return "a function"
47
+ return String(v)
48
+ }
49
+
50
+ function isEnumValue(v: unknown, names: readonly string[]): v is string {
51
+ return typeof v === "string" && (names as readonly string[]).includes(v)
52
+ }
53
+
54
+ export function parseConfig(raw: unknown): { config: Config; warnings: string[] } {
55
+ const warnings: string[] = []
56
+ const o: Record<string, unknown> = isPlainObject(raw) ? raw : {}
57
+
58
+ // source — one of the three names (§3).
59
+ let source: Config["source"] = "auto"
60
+ if (o.source !== undefined) {
61
+ if (isEnumValue(o.source, SOURCES)) {
62
+ source = o.source as Config["source"]
63
+ } else {
64
+ warnings.push(
65
+ `invalid source: ${shown(o.source)} is not one of "auto", "local", "remote"; using "auto"`,
66
+ )
67
+ }
68
+ }
69
+
70
+ // defaultPriority — one of the three Priority names.
71
+ let defaultPriority: Priority = "balanced"
72
+ if (o.defaultPriority !== undefined) {
73
+ if (isEnumValue(o.defaultPriority, PRIORITIES)) {
74
+ defaultPriority = o.defaultPriority as Priority
75
+ } else {
76
+ warnings.push(
77
+ `invalid defaultPriority: ${shown(o.defaultPriority)} is not one of` +
78
+ ` "balanced", "price", "quality"; using "balanced"`,
79
+ )
80
+ }
81
+ }
82
+
83
+ // fallback — "openrouter/<id>" with a non-empty, "#"-free remainder (§3),
84
+ // or unset. Explicit null counts as unset.
85
+ let fallback: string | null = null
86
+ const rawFallback = o.fallback
87
+ if (rawFallback !== undefined && rawFallback !== null) {
88
+ if (
89
+ typeof rawFallback === "string" &&
90
+ rawFallback.startsWith(FALLBACK_PREFIX) &&
91
+ rawFallback.length > FALLBACK_PREFIX.length &&
92
+ !rawFallback.includes("#")
93
+ ) {
94
+ fallback = rawFallback
95
+ } else {
96
+ warnings.push(
97
+ `invalid fallback: ${shown(rawFallback)} is not "openrouter/<model-id>"; treating it as unset`,
98
+ )
99
+ }
100
+ }
101
+
102
+ // weights — partial overrides per named priority. Unknown priority names,
103
+ // unknown weight keys, and values that aren't finite numbers ≥ 0 are
104
+ // dropped individually; the valid remainder is kept. An override whose
105
+ // keys all drop leaves no entry behind: an empty override would change
106
+ // downstream ranking semantics (§6) despite overriding nothing.
107
+ const weights: Config["weights"] = {}
108
+ const rawWeights = o.weights
109
+ if (rawWeights !== undefined) {
110
+ if (!isPlainObject(rawWeights)) {
111
+ warnings.push(
112
+ `invalid weights: ${shown(rawWeights)} is not an object; ignoring all weight overrides`,
113
+ )
114
+ } else {
115
+ for (const [priority, override] of Object.entries(rawWeights)) {
116
+ if (!isEnumValue(priority, PRIORITIES)) {
117
+ warnings.push(`invalid weights: unknown priority "${priority}"; dropping its override`)
118
+ continue
119
+ }
120
+ if (!isPlainObject(override)) {
121
+ warnings.push(
122
+ `invalid weights.${priority}: ${shown(override)} is not an object; dropping the override`,
123
+ )
124
+ continue
125
+ }
126
+ const kept: Partial<Weights> = {}
127
+ for (const [key, value] of Object.entries(override)) {
128
+ if (!isEnumValue(key, WEIGHT_KEYS)) {
129
+ warnings.push(`invalid weights.${priority}: unknown weight key "${key}"; dropping it`)
130
+ continue
131
+ }
132
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
133
+ warnings.push(
134
+ `invalid weights.${priority}.${key}: ${shown(value)} is not a number ≥ 0; dropping it`,
135
+ )
136
+ continue
137
+ }
138
+ kept[key as keyof Weights] = value
139
+ }
140
+ if (Object.keys(kept).length > 0) weights[priority as Priority] = kept
141
+ }
142
+ }
143
+ }
144
+
145
+ // pinDays — a finite number > 0; fractional days are fine.
146
+ let pinDays = 14
147
+ if (o.pinDays !== undefined) {
148
+ const v = o.pinDays
149
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) {
150
+ pinDays = v
151
+ } else {
152
+ warnings.push(`invalid pinDays: ${shown(v)} is not a positive number; using 14`)
153
+ }
154
+ }
155
+
156
+ // minLead — a finite number in [0, 1].
157
+ let minLead = 0.03
158
+ if (o.minLead !== undefined) {
159
+ const v = o.minLead
160
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1) {
161
+ minLead = v
162
+ } else {
163
+ warnings.push(`invalid minLead: ${shown(v)} is not a number between 0 and 1; using 0.03`)
164
+ }
165
+ }
166
+
167
+ // catalogURL — an "https://" URL; anything else gets the published catalog.
168
+ let catalogURL = DEFAULT_CATALOG_URL
169
+ if (o.catalogURL !== undefined) {
170
+ const v = o.catalogURL
171
+ if (typeof v === "string" && v.startsWith("https://")) {
172
+ catalogURL = v
173
+ } else {
174
+ warnings.push(
175
+ `invalid catalogURL: ${shown(v)} is not an "https://" URL; using the published catalog`,
176
+ )
177
+ }
178
+ }
179
+
180
+ // scriptPath — a non-empty string, or unset/null for the vendored copy.
181
+ let scriptPath: string | null = null
182
+ const rawScriptPath = o.scriptPath
183
+ if (rawScriptPath !== undefined && rawScriptPath !== null) {
184
+ if (typeof rawScriptPath === "string" && rawScriptPath.length > 0) {
185
+ scriptPath = rawScriptPath
186
+ } else {
187
+ warnings.push(
188
+ `invalid scriptPath: ${shown(rawScriptPath)} is not a non-empty string; using the vendored copy`,
189
+ )
190
+ }
191
+ }
192
+
193
+ return {
194
+ config: { source, defaultPriority, fallback, weights, pinDays, minLead, catalogURL, scriptPath },
195
+ warnings,
196
+ }
197
+ }
@@ -0,0 +1,189 @@
1
+ // src/core/catalog.ts
2
+ // Catalog document validation (design §5). Accepts schema v2 documents (only) from
3
+ // either source (vendored script or remote catalog) and projects them onto
4
+ // the plugin's Catalog type. Validation only — ranking lives elsewhere.
5
+
6
+ import type { Catalog, ModelEntry, Priority, Weights } from "./types"
7
+
8
+ const PRIORITIES: readonly Priority[] = ["balanced", "price", "quality"]
9
+ const SCORE_KEYS = ["quality", "price", "context", "age"] as const
10
+
11
+ function isObject(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value)
13
+ }
14
+
15
+ export function validateCatalog(
16
+ raw: unknown,
17
+ ): { ok: true; catalog: Catalog } | { ok: false; reason: string } {
18
+ if (!isObject(raw)) return { ok: false, reason: "catalog must be a JSON object" }
19
+
20
+ // Supported schema: v2 only — upstream's current contract (0.3.0: pricing
21
+ // scalars are effective-tier values; pricing.base/tiers/schedule and a
22
+ // top-level schedules index were added — ignored here, see the roadmap
23
+ // for consumer ideas). The plugin has never been released, so no stored
24
+ // v1 documents exist anywhere; the published catalog has served v2 since
25
+ // 2026-10-10.
26
+ if (raw.schema_version !== 2) {
27
+ return { ok: false, reason: `schema_version must be 2, got ${JSON.stringify(raw.schema_version)}` }
28
+ }
29
+ if (typeof raw.generated_at !== "string") {
30
+ return { ok: false, reason: "generated_at must be a string" }
31
+ }
32
+
33
+ // parameters.weights: optional; kept as published (possibly partial, keys
34
+ // absent upstream stay absent), except that every value must survive a
35
+ // sanity check: only the four WEIGHT_KEYS with a finite, non-negative
36
+ // number are kept (a string, NaN/±Infinity or negative weight is dropped
37
+ // like an unknown key — a hostile catalogURL serves arbitrary JSON here).
38
+ // An all-dropped map behaves as absent: with every priority map dropped,
39
+ // weights stays {} and consumers fall back to DEFAULT_WEIGHTS (design §5);
40
+ // when at least one map survives, a dropped priority reads as a missing
41
+ // one — weight 0 per the documented no-data semantics.
42
+ const weights: Partial<Record<Priority, Partial<Weights>>> = {}
43
+ const weightsRaw = isObject(raw.parameters) ? raw.parameters.weights : undefined
44
+ if (weightsRaw !== undefined) {
45
+ if (!isObject(weightsRaw)) return { ok: false, reason: "parameters.weights must be an object" }
46
+ for (const priority of PRIORITIES) {
47
+ const entry = weightsRaw[priority]
48
+ if (entry === undefined) continue
49
+ if (!isObject(entry)) {
50
+ return { ok: false, reason: `parameters.weights.${priority} must be an object` }
51
+ }
52
+ // The weight keys are the same four names as the score components.
53
+ const filtered: Partial<Weights> = {}
54
+ for (const key of SCORE_KEYS) {
55
+ const value = entry[key]
56
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) filtered[key] = value
57
+ }
58
+ if (Object.keys(filtered).length === 0) continue
59
+ weights[priority] = filtered
60
+ }
61
+ }
62
+
63
+ // models: required. Each entry needs an id and full scores (components and
64
+ // overall), plus the fields the ModelEntry type promises: a string name, an
65
+ // object pricing with a numeric blended_per_1m (the price tie-break reads
66
+ // it), and a numeric context — matching upstream, where models without
67
+ // parseable pricing land in filtered, never in models (design §5). Every
68
+ // numeric must be a FINITE number: JSON.parse("1e999") and hand-edited
69
+ // storage records can carry Infinity/NaN, and either poisons the ranking
70
+ // comparators (Infinity - Infinity is NaN) — a non-finite value rejects the
71
+ // document in the same reason style as a non-number. The optional pricing
72
+ // split (input_per_1m/output_per_1m) survives only as finite numbers — the
73
+ // values fmtPrice reads — while every other listed field is optional and
74
+ // passes through as published.
75
+ if (!Array.isArray(raw.models)) return { ok: false, reason: "models must be an array" }
76
+ const models: ModelEntry[] = []
77
+ for (let i = 0; i < raw.models.length; i++) {
78
+ const entry = raw.models[i]
79
+ if (!isObject(entry)) return { ok: false, reason: `models[${i}] must be an object` }
80
+ if (typeof entry.id !== "string") return { ok: false, reason: `models[${i}].id must be a string` }
81
+ if (!isObject(entry.scores)) return { ok: false, reason: `models[${i}].scores must be an object` }
82
+
83
+ const components = {} as Record<(typeof SCORE_KEYS)[number], number>
84
+ for (const key of SCORE_KEYS) {
85
+ const value = entry.scores[key]
86
+ if (typeof value !== "number" || !Number.isFinite(value)) {
87
+ return { ok: false, reason: `models[${i}].scores.${key} must be a number` }
88
+ }
89
+ components[key] = value
90
+ }
91
+ if (!isObject(entry.scores.overall)) {
92
+ return { ok: false, reason: `models[${i}].scores.overall must be an object` }
93
+ }
94
+ const overall = {} as Record<Priority, number>
95
+ for (const priority of PRIORITIES) {
96
+ const value = entry.scores.overall[priority]
97
+ if (typeof value !== "number" || !Number.isFinite(value)) {
98
+ return { ok: false, reason: `models[${i}].scores.overall.${priority} must be a number` }
99
+ }
100
+ overall[priority] = value
101
+ }
102
+
103
+ if (typeof entry.name !== "string") {
104
+ return { ok: false, reason: `models[${i}].name must be a string` }
105
+ }
106
+ const pricing = entry.pricing
107
+ if (!isObject(pricing)) {
108
+ return { ok: false, reason: `models[${i}].pricing must be an object` }
109
+ }
110
+ const blended = pricing.blended_per_1m
111
+ if (typeof blended !== "number" || !Number.isFinite(blended)) {
112
+ return { ok: false, reason: `models[${i}].pricing.blended_per_1m must be a number` }
113
+ }
114
+ if (typeof entry.context !== "number" || !Number.isFinite(entry.context)) {
115
+ return { ok: false, reason: `models[${i}].context must be a number` }
116
+ }
117
+
118
+ // quality is optional (number | null): a non-number is coerced to null as
119
+ // before, but a NUMBER must be finite — NaN/Infinity would poison the
120
+ // quality tie-break in byRankingKey.
121
+ if (typeof entry.quality === "number" && !Number.isFinite(entry.quality)) {
122
+ return { ok: false, reason: `models[${i}].quality must be a finite number` }
123
+ }
124
+ const quality = typeof entry.quality === "number" ? entry.quality : null
125
+ // The optional pricing split: kept only when finite numbers. A hostile
126
+ // catalogURL can serve any JSON here and fmtPrice reads these as numbers
127
+ // — a non-numeric or non-finite value is dropped to undefined (absent
128
+ // stays absent, present-but-invalid behaves as absent).
129
+ const input = pricing.input_per_1m
130
+ const output = pricing.output_per_1m
131
+ models.push({
132
+ ...entry,
133
+ id: entry.id,
134
+ name: entry.name,
135
+ pricing: {
136
+ ...pricing,
137
+ blended_per_1m: blended,
138
+ input_per_1m: typeof input === "number" && Number.isFinite(input) ? input : undefined,
139
+ output_per_1m: typeof output === "number" && Number.isFinite(output) ? output : undefined,
140
+ },
141
+ context: entry.context,
142
+ scores: { ...components, overall },
143
+ quality,
144
+ })
145
+ }
146
+
147
+ // rankings: optional; when present, each known priority must map to an
148
+ // ordered list of model ids. Unknown priority keys are ignored.
149
+ const rankings: Partial<Record<Priority, string[]>> = {}
150
+ if (raw.rankings !== undefined) {
151
+ if (!isObject(raw.rankings)) return { ok: false, reason: "rankings must be an object" }
152
+ for (const priority of PRIORITIES) {
153
+ const list = raw.rankings[priority]
154
+ if (list === undefined) continue
155
+ if (!Array.isArray(list) || !list.every((id) => typeof id === "string")) {
156
+ return { ok: false, reason: `rankings.${priority} must be an array of strings` }
157
+ }
158
+ rankings[priority] = [...(list as string[])]
159
+ }
160
+ }
161
+
162
+ return {
163
+ ok: true,
164
+ catalog: {
165
+ generatedAt: raw.generated_at,
166
+ weights,
167
+ rankings,
168
+ models,
169
+ },
170
+ }
171
+ }
172
+
173
+ // Re-validates a catalog read back from plugin storage (the projected
174
+ // `Catalog`, not the raw upstream document) by rebuilding the raw shape and
175
+ // running it through validateCatalog: a corrupt or hand-edited record is
176
+ // rejected instead of reaching ranking code. The round trip is lossless for a
177
+ // catalog validateCatalog produced.
178
+ export function validateStoredCatalog(
179
+ stored: unknown,
180
+ ): { ok: true; catalog: Catalog } | { ok: false; reason: string } {
181
+ if (!isObject(stored)) return { ok: false, reason: "stored catalog must be an object" }
182
+ return validateCatalog({
183
+ schema_version: 2,
184
+ generated_at: stored.generatedAt,
185
+ parameters: { weights: stored.weights },
186
+ models: stored.models,
187
+ rankings: stored.rankings,
188
+ })
189
+ }
@@ -0,0 +1,12 @@
1
+ // src/core/hash.ts
2
+ // 32-bit FNV-1a as lowercase hex. Used to derive storage keys (pin records by
3
+ // effective weights §7.2, the document cache by source identity) — not a
4
+ // security primitive.
5
+ export function fnv1aHex(input: string): string {
6
+ let hash = 0x811c9dc5
7
+ for (let i = 0; i < input.length; i++) {
8
+ hash ^= input.charCodeAt(i)
9
+ hash = Math.imul(hash, 0x01000193)
10
+ }
11
+ return (hash >>> 0).toString(16).padStart(8, "0")
12
+ }
@@ -0,0 +1,158 @@
1
+ // src/core/pins.ts
2
+ // Pin/last-known-good state and pick resolution (design §7, §9). Pure
3
+ // decision logic over a validated Catalog — no I/O, no OpenCode imports; the
4
+ // caller supplies runnability, the stored pin, and the clock.
5
+
6
+ import type { Catalog, Priority, Weights } from "./types"
7
+ import { fnv1aHex } from "./hash"
8
+ import { effectiveWeights, overallScore, rankModels } from "./rank"
9
+
10
+ // One record per priority and effective-weights hash: both the pin and the
11
+ // last-known-good pick (§7.2).
12
+ export type Pin = { v: 1; id: string; since: number }
13
+
14
+ export type ResolveInput = {
15
+ catalog: Catalog | null
16
+ p: Priority
17
+ override?: Partial<Weights>
18
+ // True when OpenCode lists `openrouter/<id>` as an enabled model (§7.1).
19
+ // Receives bare catalog ids.
20
+ runnable: (id: string) => boolean
21
+ // Config value like "openrouter/<id>", or null (§3).
22
+ fallback: string | null
23
+ stored: Pin | null
24
+ now: number
25
+ pinDays: number
26
+ minLead: number
27
+ }
28
+
29
+ export type ResolveResult = {
30
+ targetId: string | null
31
+ source: "pick" | "pin" | "fallback" | "none"
32
+ // The pin state after this resolution: a freshly adopted/kept record, or
33
+ // null only when nothing was adopted and nothing is stored (§7.8 keeps the
34
+ // fallback out of the pin; §9 skips, never deletes, an unrunnable record).
35
+ pin: Pin | null
36
+ notices: string[]
37
+ }
38
+
39
+ const PROVIDER_PREFIX = "openrouter/"
40
+ const DAY_MS = 86_400_000
41
+
42
+ // Config values look like "openrouter/<id>"; catalog ids are bare. Strip
43
+ // exactly one leading prefix — a bare id may itself contain slashes
44
+ // (provider/model), so nothing beyond the prefix is touched.
45
+ function bareModelId(configValue: string): string {
46
+ return configValue.startsWith(PROVIDER_PREFIX)
47
+ ? configValue.slice(PROVIDER_PREFIX.length)
48
+ : configValue
49
+ }
50
+
51
+ export { fnv1aHex }
52
+
53
+ export function pinKey(p: Priority, w: Weights): string {
54
+ return `pin/${p}/${fnv1aHex(JSON.stringify(w))}`
55
+ }
56
+
57
+ // §9's chain when there is no runnable pick from ranking: configured fallback
58
+ // (checked by bare id) → none. `stored` rides along as the pin state: the
59
+ // fallback itself is never stored as a pin (§7.8).
60
+ function fallbackOrNone(
61
+ fallback: string | null,
62
+ runnable: (id: string) => boolean,
63
+ stored: Pin | null,
64
+ notices: string[],
65
+ ): ResolveResult {
66
+ if (fallback !== null) {
67
+ const bare = bareModelId(fallback)
68
+ if (runnable(bare)) {
69
+ notices.push(`fallback used: ${bare}`)
70
+ return { targetId: bare, source: "fallback", pin: stored, notices }
71
+ }
72
+ }
73
+ return { targetId: null, source: "none", pin: stored, notices }
74
+ }
75
+
76
+ export function resolve(input: ResolveInput): ResolveResult {
77
+ const { catalog, p, override, runnable, fallback, stored, now, pinDays, minLead } = input
78
+ const notices: string[] = []
79
+
80
+ // Without a fresh catalog (§9): stored pin (if runnable) → fallback (if
81
+ // runnable) → none. No rank-related notices — there is no ranking here.
82
+ if (catalog === null) {
83
+ if (stored && runnable(stored.id)) {
84
+ return { targetId: stored.id, source: "pin", pin: stored, notices }
85
+ }
86
+ if (stored) notices.push(`pin skipped: ${stored.id} is not runnable`)
87
+ return fallbackOrNone(fallback, runnable, stored, notices)
88
+ }
89
+
90
+ // Fresh catalog (§7.4): rank under the current weights, then walk the order
91
+ // for the first runnable candidate.
92
+ const eff = effectiveWeights(catalog, p, override)
93
+ // "Override ignored" notices: whatever the weight merge chose to ignore.
94
+ notices.push(...eff.warnings)
95
+ const ranked = rankModels(catalog, p, override).ids
96
+ const byId = new Map(catalog.models.map((m) => [m.id, m]))
97
+ const best = ranked.find((id) => runnable(id)) ?? null
98
+
99
+ // §9 when the fresh rules yield no runnable candidate: same chain as the
100
+ // no-catalog path.
101
+ if (best === null) {
102
+ if (stored && runnable(stored.id)) {
103
+ return { targetId: stored.id, source: "pin", pin: stored, notices }
104
+ }
105
+ if (stored) notices.push(`pin skipped: ${stored.id} is not runnable`)
106
+ return fallbackOrNone(fallback, runnable, stored, notices)
107
+ }
108
+
109
+ // (Re)adopt the best candidate; the target staying the stored pin id (only
110
+ // possible on the expiry re-adoption below) still counts as "pin".
111
+ const adopt = (id: string): ResolveResult => ({
112
+ targetId: id,
113
+ source: stored !== null && id === stored.id ? "pin" : "pick",
114
+ pin: { v: 1, id, since: now },
115
+ notices,
116
+ })
117
+
118
+ if (stored === null) return adopt(best)
119
+
120
+ const pinEntry = byId.get(stored.id) ?? null
121
+
122
+ // (a) The pinned model is not runnable, or has left the pool — §7.4 says
123
+ // switch to the best candidate now. (A ranking entry outside the pool could
124
+ // still name the pin id; that degenerates into a same-id re-adoption.)
125
+ if (!runnable(stored.id) || pinEntry === null) {
126
+ if (best !== stored.id) notices.push(`pin switched: ${stored.id} → ${best}`)
127
+ return adopt(best)
128
+ }
129
+
130
+ // (b) The pin is older than pinDays: re-adopt the best candidate — which
131
+ // may be the pinned model itself — and reset `since` (§7.4 "which may be
132
+ // the same model"; §7.6 "since resets every time a pin is adopted or
133
+ // re-adopted").
134
+ if (now - stored.since > pinDays * DAY_MS) {
135
+ if (best !== stored.id) notices.push(`pin switched: ${stored.id} → ${best}`)
136
+ return adopt(best)
137
+ }
138
+
139
+ // Nothing changed: keep the pin exactly as stored, clock untouched.
140
+ if (best === stored.id) {
141
+ return { targetId: stored.id, source: "pin", pin: stored, notices }
142
+ }
143
+
144
+ // (c) Switch only when the challenger leads by ≥ minLead; both scores come
145
+ // from the current catalog under the current effective weights (§7.4). A
146
+ // challenger missing from the pool can't be scored, so it can't lead.
147
+ const bestEntry = byId.get(best) ?? null
148
+ const lead =
149
+ bestEntry === null
150
+ ? Number.NEGATIVE_INFINITY
151
+ : overallScore(bestEntry, eff.weights) - overallScore(pinEntry, eff.weights)
152
+ if (lead >= minLead) {
153
+ notices.push(`pin switched: ${stored.id} → ${best}`)
154
+ return adopt(best)
155
+ }
156
+
157
+ return { targetId: stored.id, source: "pin", pin: stored, notices }
158
+ }