polyc_llm/model_info.rs
1//! Bundled model catalog: per-model context-window facts and the longest-prefix
2//! lookup that drives compaction.
3//!
4//! The catalog is a compile-checked Rust table (no JSON file, no runtime
5//! parsing). It is the single home for "how big is this model's window". The
6//! control plane (compaction trigger, via `model_select`) is the consumer today;
7//! it lives in the shared `polyc-llm` crate so a future harness-side reader gets
8//! the same numbers rather than a divergent copy. An unknown slug never refuses —
9//! it falls back to a conservative window with a single `tracing::warn`, taking
10//! a "warn, don't fail" stance.
11
12/// Per-model context facts, looked up by slug.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct ModelInfo {
15 /// Maximum total tokens (prompt + generation) the model accepts.
16 pub context_window: u32,
17 /// Whole-percent fraction of `context_window` that is considered usable
18 /// (default 95). usable = `context_window` * pct / 100.
19 pub effective_context_window_percent: u16,
20}
21
22impl ModelInfo {
23 /// Default usable fraction of a model's window — reserved headroom so the
24 /// prompt never runs flush against the hard limit.
25 pub const DEFAULT_EFFECTIVE_PERCENT: u16 = 95;
26
27 /// A catalog entry with the default effective percentage.
28 #[must_use]
29 pub const fn new(context_window: u32) -> Self {
30 Self {
31 context_window,
32 effective_context_window_percent: Self::DEFAULT_EFFECTIVE_PERCENT,
33 }
34 }
35
36 /// floor(`context_window` * `effective_context_window_percent` / 100): the
37 /// usable token budget after reserving headroom.
38 #[must_use]
39 pub const fn effective_window(self) -> u32 {
40 let usable = (self.context_window as u64 * self.effective_context_window_percent as u64)
41 / Self::PERCENT_DENOM;
42 // `usable <= context_window` (the percent is a fraction of 100), so it
43 // always fits a u32; clamp defensively rather than wrap on the cast.
44 // `u32::try_from` is not yet const-stable, so the checked cast is
45 // expressed manually.
46 if usable > u32::MAX as u64 {
47 u32::MAX
48 } else {
49 // SAFETY (value): guarded above to fit in u32, so this never truncates.
50 #[allow(clippy::cast_possible_truncation)]
51 {
52 usable as u32
53 }
54 }
55 }
56
57 /// Whole-percent denominator for [`effective_window`](Self::effective_window).
58 const PERCENT_DENOM: u64 = 100;
59}
60
61/// Conservative fallback window for an unknown slug. `128_000` chosen so an
62/// unknown model compacts early rather than overflowing a real (possibly
63/// smaller) window.
64pub const FALLBACK_CONTEXT_WINDOW: u32 = 128_000;
65
66/// The bundled catalog. Keys are matched by longest prefix (see
67/// [`lookup_model`]), so dated/preview suffixes resolve to their base entry and
68/// more-specific slugs (`gpt-4o`) beat shorter ones (`gpt-4`).
69static CATALOG: &[(&str, ModelInfo)] = &[
70 // Gemini 3+ only — pre-3 (2.5 and earlier) is deprecated and unsupported.
71 // The `gemini-3` catch-all covers 3 / 3.1 / flash / pro / flash-lite (all 1M).
72 ("gemini-3-flash", ModelInfo::new(1_048_576)),
73 ("gemini-3.1-pro", ModelInfo::new(1_048_576)),
74 ("gemini-3-pro", ModelInfo::new(1_048_576)),
75 ("gemini-3", ModelInfo::new(1_048_576)),
76 ("gpt-4o", ModelInfo::new(128_000)),
77 ("gpt-4-turbo", ModelInfo::new(128_000)),
78 ("gpt-4", ModelInfo::new(8_192)),
79 ("llama3.2", ModelInfo::new(131_072)),
80 ("llama3", ModelInfo::new(131_072)),
81];
82
83/// Longest-prefix match.
84///
85/// Iterate the catalog and keep the entry whose key is a prefix of `slug`
86/// (`slug.starts_with(key)`) with the GREATEST `key.len()`. An exact match is
87/// naturally the longest possible prefix. Returns `None` if no key is a prefix.
88#[must_use]
89pub fn lookup_model(slug: &str) -> Option<ModelInfo> {
90 CATALOG
91 .iter()
92 .filter(|(k, _)| slug.starts_with(k))
93 .max_by_key(|(k, _)| k.len())
94 .map(|(_, info)| *info)
95}
96
97/// [`lookup_model`] with the fallback applied plus ONE `tracing::warn` naming
98/// the slug and the fallback window. NEVER refuses.
99#[must_use]
100pub fn lookup_model_or_fallback(slug: &str) -> ModelInfo {
101 lookup_model(slug).unwrap_or_else(|| {
102 tracing::warn!(
103 model = %slug,
104 fallback_context_window = FALLBACK_CONTEXT_WINDOW,
105 "unknown model slug; using conservative fallback context window"
106 );
107 ModelInfo::new(FALLBACK_CONTEXT_WINDOW)
108 })
109}
110
111#[cfg(test)]
112mod tests {
113 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
114
115 use super::*;
116
117 #[test]
118 fn exact_match_resolves() {
119 assert_eq!(lookup_model("gpt-4").unwrap().context_window, 8_192);
120 assert_eq!(lookup_model("gpt-4o").unwrap().context_window, 128_000);
121 }
122
123 #[test]
124 fn longest_prefix_beats_shorter_keys() {
125 // A dated gpt-4o slug must resolve to gpt-4o (128k), NOT gpt-4 (8k).
126 assert_eq!(
127 lookup_model("gpt-4o-2024-08-06").unwrap().context_window,
128 128_000
129 );
130 // gpt-4-turbo beats gpt-4.
131 assert_eq!(
132 lookup_model("gpt-4-turbo-2024").unwrap().context_window,
133 128_000
134 );
135 }
136
137 #[test]
138 fn preview_and_tag_suffixes_resolve_to_base() {
139 assert_eq!(
140 lookup_model("gemini-3.1-pro-preview")
141 .unwrap()
142 .context_window,
143 1_048_576
144 );
145 assert_eq!(
146 lookup_model("gemini-3-flash-preview-09-2025")
147 .unwrap()
148 .context_window,
149 1_048_576
150 );
151 assert_eq!(
152 lookup_model("llama3.2:latest").unwrap().context_window,
153 131_072
154 );
155 }
156
157 #[test]
158 fn unknown_slug_has_no_catalog_entry_but_falls_back() {
159 assert!(lookup_model("foo-model").is_none());
160 assert_eq!(
161 lookup_model_or_fallback("foo-model").context_window,
162 FALLBACK_CONTEXT_WINDOW
163 );
164 }
165
166 #[test]
167 fn effective_window_math() {
168 assert_eq!(ModelInfo::new(1_048_576).effective_window(), 996_147);
169 assert_eq!(ModelInfo::new(128_000).effective_window(), 121_600);
170 }
171}