Skip to main content

vfa_tui/models/
model_registry.rs

1//! Verified model / reasoning-effort matrix (`catalog/model-registry.json`).
2//!
3//! The registry is the fail-closed allowlist `scripts/model-policy.mjs`
4//! validates every policy value against. The TUI reads it for one purpose
5//! only: to offer the operator the *same* choices the script would accept,
6//! instead of a free-text box plus a hardcoded effort union that drifts.
7//!
8//! Read-first principle: this module never classifies an arbitrary value into
9//! a namespace and never decides whether a pin is legal — that is the script's
10//! job, and duplicating its regex evaluation here is exactly the kind of logic
11//! fork the TUI is meant to avoid. It only walks the already-declared
12//! structure, so a choice the operator *picks* carries its namespace with it
13//! and the effort narrowing follows from that, with no matching required.
14//! Free-typed values fall back to the harness vocabulary and are left for the
15//! script to accept or reject.
16
17use std::collections::HashMap;
18
19use serde::{Deserialize, Serialize};
20
21/// Whether a namespace enumerates its models (`closed`) or validates shape
22/// only because the catalog is too large/volatile to list (`open`).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Membership {
26    Closed,
27    Open,
28}
29
30/// Provider lifecycle state of a registered model. Absent means available.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum ModelStatus {
34    #[default]
35    Available,
36    Retiring,
37    Retired,
38}
39
40/// One entry in a closed namespace's verified allowlist.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct RegistryModel {
44    pub id: String,
45    /// Effort values this model supports. `None` inherits the namespace (or
46    /// harness) vocabulary; `Some([])` is a non-reasoning model.
47    #[serde(default)]
48    pub reasoning_efforts: Option<Vec<String>>,
49    pub last_verified: String,
50    #[serde(default)]
51    pub source: Option<String>,
52    #[serde(default)]
53    pub note: Option<String>,
54    #[serde(default)]
55    pub status: Option<ModelStatus>,
56    #[serde(default)]
57    pub retirement_date: Option<String>,
58    #[serde(default)]
59    pub successor: Option<String>,
60}
61
62impl RegistryModel {
63    pub fn status(&self) -> ModelStatus {
64        self.status.unwrap_or_default()
65    }
66}
67
68/// A namespace groups model values that share a routing target and vocabulary.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct RegistryNamespace {
72    pub id: String,
73    #[serde(default)]
74    pub description: Option<String>,
75    /// Anchored regex the script uses to classify a value into this namespace.
76    /// Carried for display/provenance only — never evaluated here.
77    #[serde(rename = "match")]
78    pub match_pattern: String,
79    pub membership: Membership,
80    #[serde(default)]
81    pub model_provider: Option<String>,
82    #[serde(default)]
83    pub requires_provider_table: Option<bool>,
84    #[serde(default)]
85    pub reasoning_efforts: Option<Vec<String>>,
86    #[serde(default)]
87    pub reasoning_note: Option<String>,
88    #[serde(default)]
89    pub sources: Vec<String>,
90    #[serde(default)]
91    pub examples: Vec<String>,
92    #[serde(default)]
93    pub models: Vec<RegistryModel>,
94}
95
96/// Per-harness section of the registry.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct RegistryHarness {
100    /// Config key the harness projects reasoning effort into; `None` means the
101    /// harness has no reasoning field at all.
102    pub reasoning_key: Option<String>,
103    pub reasoning_efforts: Vec<String>,
104    #[serde(default)]
105    pub reasoning_note: Option<String>,
106    pub namespaces: Vec<RegistryNamespace>,
107}
108
109impl RegistryHarness {
110    /// Whether this harness can express a reasoning effort at all.
111    pub fn supports_reasoning(&self) -> bool {
112        self.reasoning_key.is_some() && !self.reasoning_efforts.is_empty()
113    }
114}
115
116/// Top-level structure of `catalog/model-registry.json`.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct ModelRegistry {
120    pub manifest_version: u32,
121    #[serde(default)]
122    pub description: Option<String>,
123    pub last_refreshed: String,
124    pub harnesses: HashMap<String, RegistryHarness>,
125}
126
127/// One selectable entry in the TUI's model picker, already resolved to the
128/// effort vocabulary that applies to it.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ModelChoice {
131    pub model: String,
132    pub namespace: String,
133    /// Effort values valid for this model: the model's own list when it
134    /// declares one, else the namespace's, else the harness vocabulary.
135    pub efforts: Vec<String>,
136    pub status: ModelStatus,
137    /// True when the entry came from an open namespace's advisory `examples`
138    /// rather than a verified allowlist — pickable, but not an allowlist hit.
139    pub is_example: bool,
140    pub retirement_date: Option<String>,
141    pub successor: Option<String>,
142    pub note: Option<String>,
143}
144
145impl ModelChoice {
146    /// Short suffix describing lifecycle / provenance for the picker line.
147    pub fn label_suffix(&self) -> String {
148        let mut parts: Vec<String> = Vec::new();
149        match self.status {
150            ModelStatus::Retiring => parts.push(match &self.retirement_date {
151                Some(d) => format!("retiring {d}"),
152                None => "retiring".to_string(),
153            }),
154            ModelStatus::Retired => parts.push(match &self.successor {
155                Some(s) => format!("retired -> {s}"),
156                None => "retired".to_string(),
157            }),
158            ModelStatus::Available => {}
159        }
160        if self.is_example {
161            parts.push("example".to_string());
162        }
163        if self.efforts.is_empty() {
164            parts.push("no effort".to_string());
165        }
166        if parts.is_empty() {
167            String::new()
168        } else {
169            format!(" ({})", parts.join(", "))
170        }
171    }
172}
173
174/// Follow a `retired` model's `successor` chain within its namespace to the
175/// entry the policy engine will actually project, so effort narrowing is read
176/// off that model rather than the retired one.
177///
178/// Bounded by the namespace size and guarded against a cycle or a dangling
179/// successor: either simply stops, returning the last entry reached, which is
180/// the conservative choice — the script re-validates regardless.
181fn resolve_successor<'a>(ns: &'a RegistryNamespace, model: &'a RegistryModel) -> &'a RegistryModel {
182    let mut current = model;
183    for _ in 0..ns.models.len() {
184        if current.status() != ModelStatus::Retired {
185            break;
186        }
187        let Some(successor) = current.successor.as_deref() else {
188            break;
189        };
190        let Some(next) = ns.models.iter().find(|m| m.id == successor) else {
191            break;
192        };
193        if next.id == current.id {
194            break;
195        }
196        current = next;
197    }
198    current
199}
200
201impl ModelRegistry {
202    pub fn harness(&self, harness: &str) -> Option<&RegistryHarness> {
203        self.harnesses.get(harness)
204    }
205
206    /// Complete effort vocabulary a harness accepts, used when the operator
207    /// free-types a model the registry does not enumerate.
208    pub fn harness_efforts(&self, harness: &str) -> Vec<String> {
209        self.harness(harness)
210            .map(|h| h.reasoning_efforts.clone())
211            .unwrap_or_default()
212    }
213
214    pub fn supports_reasoning(&self, harness: &str) -> bool {
215        self.harness(harness)
216            .is_some_and(|h| h.supports_reasoning())
217    }
218
219    /// Every model the operator can pick for `harness`, in registry order:
220    /// each namespace's verified allowlist first, then the advisory examples
221    /// of open namespaces. Duplicate ids keep their first (most specific)
222    /// occurrence, mirroring the script's first-namespace-wins ordering.
223    pub fn choices_for_harness(&self, harness: &str) -> Vec<ModelChoice> {
224        let Some(hdef) = self.harness(harness) else {
225            return Vec::new();
226        };
227        let mut out: Vec<ModelChoice> = Vec::new();
228        let mut seen: Vec<String> = Vec::new();
229
230        let push = |choice: ModelChoice, out: &mut Vec<ModelChoice>, seen: &mut Vec<String>| {
231            if seen.iter().any(|m| m == &choice.model) {
232                return;
233            }
234            seen.push(choice.model.clone());
235            out.push(choice);
236        };
237
238        for ns in &hdef.namespaces {
239            let ns_efforts = ns
240                .reasoning_efforts
241                .clone()
242                .unwrap_or_else(|| hdef.reasoning_efforts.clone());
243
244            for m in &ns.models {
245                // A "retired" model is not what the engine validates against:
246                // resolveAll in scripts/model-policy.mjs substitutes the
247                // documented successor (chain-followed) and checks the effort
248                // against *that* model. Mirror it, or the picker would offer
249                // the retired entry's vocabulary for a pin the script resolves
250                // elsewhere — offering a value the script then rejects, or
251                // hiding one it would accept.
252                let effective = resolve_successor(ns, m);
253                let efforts = effective
254                    .reasoning_efforts
255                    .clone()
256                    .unwrap_or_else(|| ns_efforts.clone());
257                push(
258                    ModelChoice {
259                        model: m.id.clone(),
260                        namespace: ns.id.clone(),
261                        efforts,
262                        status: m.status(),
263                        is_example: false,
264                        retirement_date: m.retirement_date.clone(),
265                        successor: m.successor.clone(),
266                        note: m.note.clone(),
267                    },
268                    &mut out,
269                    &mut seen,
270                );
271            }
272
273            // Open namespaces cannot enumerate their catalog; their `examples`
274            // are advisory but they are exactly the values an operator wants
275            // one keypress away (local Ollama tags, OpenRouter slugs).
276            if ns.membership == Membership::Open {
277                for ex in &ns.examples {
278                    push(
279                        ModelChoice {
280                            model: ex.clone(),
281                            namespace: ns.id.clone(),
282                            efforts: ns_efforts.clone(),
283                            status: ModelStatus::Available,
284                            is_example: true,
285                            retirement_date: None,
286                            successor: None,
287                            note: None,
288                        },
289                        &mut out,
290                        &mut seen,
291                    );
292                }
293            }
294        }
295        out
296    }
297
298    /// The choice matching an exact model id, if the registry enumerates it.
299    /// Exact-match only — no namespace regex is evaluated, so a free-typed
300    /// value simply returns `None` and the caller falls back to the harness
301    /// vocabulary rather than guessing at a classification.
302    pub fn choice_for(&self, harness: &str, model: &str) -> Option<ModelChoice> {
303        self.choices_for_harness(harness)
304            .into_iter()
305            .find(|c| c.model == model)
306    }
307
308    /// Effort values to offer for `model` under `harness`. A registered model
309    /// narrows to its own vocabulary; anything else gets the full harness
310    /// vocabulary and is left for `scripts/model-policy.mjs` to adjudicate.
311    pub fn efforts_for(&self, harness: &str, model: &str) -> Vec<String> {
312        match self.choice_for(harness, model) {
313            Some(choice) => choice.efforts,
314            None => self.harness_efforts(harness),
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn fixture() -> ModelRegistry {
324        let json = r#"{
325          "manifest_version": 1,
326          "description": "test",
327          "last_refreshed": "2026-09-05",
328          "harnesses": {
329            "codex": {
330              "reasoning_key": "model_reasoning_effort",
331              "reasoning_efforts": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
332              "namespaces": [
333                {
334                  "id": "openai",
335                  "match": "^(gpt-|o[0-9])[a-z0-9.-]*$",
336                  "membership": "closed",
337                  "model_provider": null,
338                  "models": [
339                    { "id": "gpt-6-astra", "reasoning_efforts": ["low", "high", "max"], "last_verified": "2026-09-05" },
340                    { "id": "gpt-4.1-mini", "reasoning_efforts": [], "last_verified": "2026-09-05" },
341                    { "id": "gpt-old", "last_verified": "2026-09-05", "status": "retiring", "retirement_date": "2026-12-11", "successor": "gpt-6-astra" }
342                  ]
343                },
344                {
345                  "id": "ollama",
346                  "match": "^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$",
347                  "membership": "open",
348                  "model_provider": "ollama",
349                  "requires_provider_table": true,
350                  "reasoning_efforts": ["none", "low", "medium", "high", "max"],
351                  "examples": ["qwen3:32b", "glm-5.3:cloud"]
352                }
353              ]
354            },
355            "cursor": {
356              "reasoning_key": null,
357              "reasoning_efforts": [],
358              "namespaces": [
359                { "id": "named", "match": "^[a-z0-9][a-z0-9.-]*$", "membership": "closed",
360                  "models": [{ "id": "composer-2.5", "last_verified": "2026-09-05" }] }
361              ]
362            }
363          }
364        }"#;
365        serde_json::from_str(json).expect("fixture parses")
366    }
367
368    #[test]
369    fn parses_registry_shape() {
370        let reg = fixture();
371        assert_eq!(reg.manifest_version, 1);
372        assert_eq!(reg.last_refreshed, "2026-09-05");
373        assert_eq!(reg.harness("codex").unwrap().namespaces.len(), 2);
374    }
375
376    #[test]
377    fn choices_include_closed_models_and_open_examples() {
378        let ids: Vec<String> = fixture()
379            .choices_for_harness("codex")
380            .into_iter()
381            .map(|c| c.model)
382            .collect();
383        assert_eq!(
384            ids,
385            vec![
386                "gpt-6-astra",
387                "gpt-4.1-mini",
388                "gpt-old",
389                "qwen3:32b",
390                "glm-5.3:cloud"
391            ]
392        );
393    }
394
395    #[test]
396    fn open_namespace_entries_are_marked_as_examples() {
397        let reg = fixture();
398        assert!(!reg.choice_for("codex", "gpt-6-astra").unwrap().is_example);
399        let tag = reg.choice_for("codex", "qwen3:32b").unwrap();
400        assert!(tag.is_example);
401        assert_eq!(tag.namespace, "ollama");
402    }
403
404    #[test]
405    fn effort_narrowing_prefers_model_then_namespace_then_harness() {
406        let reg = fixture();
407        // model-level list wins
408        assert_eq!(
409            reg.efforts_for("codex", "gpt-6-astra"),
410            vec!["low", "high", "max"]
411        );
412        // namespace-level list wins where the model declares none
413        assert_eq!(
414            reg.efforts_for("codex", "qwen3:32b"),
415            vec!["none", "low", "medium", "high", "max"]
416        );
417        // model with no list inherits the harness vocabulary
418        assert_eq!(reg.efforts_for("codex", "gpt-old").len(), 7);
419    }
420
421    #[test]
422    fn non_reasoning_model_offers_no_efforts() {
423        assert!(fixture().efforts_for("codex", "gpt-4.1-mini").is_empty());
424    }
425
426    #[test]
427    fn free_typed_model_falls_back_to_harness_vocabulary() {
428        let reg = fixture();
429        // Deliberately never classified against a namespace regex.
430        assert!(reg.choice_for("codex", "deepseek-r1:70b").is_none());
431        assert_eq!(reg.efforts_for("codex", "deepseek-r1:70b").len(), 7);
432    }
433
434    #[test]
435    fn harness_without_reasoning_key_reports_unsupported() {
436        let reg = fixture();
437        assert!(reg.supports_reasoning("codex"));
438        assert!(!reg.supports_reasoning("cursor"));
439        assert!(!reg.supports_reasoning("nonexistent"));
440        assert!(reg.choices_for_harness("nonexistent").is_empty());
441    }
442
443    #[test]
444    fn lifecycle_surfaces_in_the_picker_label() {
445        let reg = fixture();
446        let retiring = reg.choice_for("codex", "gpt-old").unwrap();
447        assert_eq!(retiring.status, ModelStatus::Retiring);
448        assert!(retiring.label_suffix().contains("retiring 2026-12-11"));
449        assert!(reg
450            .choice_for("codex", "gpt-4.1-mini")
451            .unwrap()
452            .label_suffix()
453            .contains("no effort"));
454        assert!(reg
455            .choice_for("codex", "qwen3:32b")
456            .unwrap()
457            .label_suffix()
458            .contains("example"));
459        assert_eq!(
460            reg.choice_for("codex", "gpt-6-astra")
461                .unwrap()
462                .label_suffix(),
463            ""
464        );
465    }
466
467    #[test]
468    fn retired_model_takes_its_successors_efforts() {
469        // The engine projects the successor for a retired pin and validates the
470        // effort against it, so the picker must narrow the same way.
471        let json = r#"{
472          "manifest_version": 1,
473          "last_refreshed": "2026-09-05",
474          "harnesses": {
475            "codex": {
476              "reasoning_key": "model_reasoning_effort",
477              "reasoning_efforts": ["none", "low", "medium", "high", "xhigh", "max"],
478              "namespaces": [
479                {
480                  "id": "openai",
481                  "match": "^gpt-[a-z0-9.-]*$",
482                  "membership": "closed",
483                  "models": [
484                    { "id": "gpt-dead", "reasoning_efforts": ["low"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-mid" },
485                    { "id": "gpt-mid", "reasoning_efforts": ["medium"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-live" },
486                    { "id": "gpt-live", "reasoning_efforts": ["none", "high", "max"], "last_verified": "2026-09-05" },
487                    { "id": "gpt-loop", "reasoning_efforts": ["low"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-loop" },
488                    { "id": "gpt-dangling", "reasoning_efforts": ["low"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-missing" }
489                  ]
490                }
491              ]
492            },
493            "claude-code": { "reasoning_key": "effort", "reasoning_efforts": ["high"], "namespaces": [
494              { "id": "alias", "match": "^opus$", "membership": "closed", "models": [{ "id": "opus", "last_verified": "2026-09-05" }] } ] },
495            "cursor": { "reasoning_key": null, "reasoning_efforts": [], "namespaces": [
496              { "id": "named", "match": "^[a-z0-9-]+$", "membership": "closed", "models": [{ "id": "composer-2", "last_verified": "2026-09-05" }] } ] }
497          }
498        }"#;
499        let reg: ModelRegistry = serde_json::from_str(json).expect("parse");
500        // Chain is followed to the end, not one hop.
501        assert_eq!(
502            reg.efforts_for("codex", "gpt-dead"),
503            vec!["none", "high", "max"]
504        );
505        assert_eq!(
506            reg.efforts_for("codex", "gpt-mid"),
507            vec!["none", "high", "max"]
508        );
509        // A live model is untouched.
510        assert_eq!(
511            reg.efforts_for("codex", "gpt-live"),
512            vec!["none", "high", "max"]
513        );
514        // A self-referential or dangling successor must terminate, not hang.
515        assert_eq!(reg.efforts_for("codex", "gpt-loop"), vec!["low"]);
516        assert_eq!(reg.efforts_for("codex", "gpt-dangling"), vec!["low"]);
517        // The retired entry still renders as retired in the picker.
518        assert!(reg
519            .choice_for("codex", "gpt-dead")
520            .unwrap()
521            .label_suffix()
522            .contains("retired -> gpt-mid"));
523    }
524
525    #[test]
526    fn unknown_fields_are_rejected() {
527        // deny_unknown_fields keeps the TUI honest when the generator adds a key.
528        let json = r#"{ "id": "x", "last_verified": "2026-09-05", "surprise": true }"#;
529        assert!(serde_json::from_str::<RegistryModel>(json).is_err());
530    }
531
532    #[test]
533    fn parses_the_committed_registry() {
534        // Guards against a registry change that the TUI's strict structs
535        // would reject at runtime (the catalog would silently stop loading).
536        let path = concat!(
537            env!("CARGO_MANIFEST_DIR"),
538            "/../../catalog/model-registry.json"
539        );
540        let data = std::fs::read_to_string(path).expect("committed registry is readable");
541        let reg: ModelRegistry = serde_json::from_str(&data).expect("committed registry parses");
542        for harness in ["codex", "claude-code", "cursor"] {
543            assert!(
544                !reg.choices_for_harness(harness).is_empty(),
545                "{harness} should offer at least one model choice"
546            );
547        }
548    }
549}