Skip to main content

gglib_core/domain/
query.rs

1//! Model list query, filter, and sort types.
2//!
3//! This module is the **single source of truth** for model filtering and
4//! sorting logic. Both the CLI (direct-mode) and the Axum HTTP handler
5//! delegate here; the GUI sends HTTP query parameters that are deserialized
6//! into [`ModelListQuery`] on the server side. No filter/sort logic is
7//! duplicated in the frontend.
8
9use serde::{Deserialize, Serialize};
10
11use crate::domain::Model;
12
13// ─────────────────────────────────────────────────────────────────────────────
14// Sort / order enums
15// ─────────────────────────────────────────────────────────────────────────────
16
17/// The field to sort the model list by.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ModelSortBy {
21    /// Sort by when the model was added (most recent first by default).
22    #[default]
23    AddedAt,
24    /// Sort alphabetically by model name.
25    Name,
26    /// Sort by parameter count (in billions).
27    ParamCount,
28    /// Sort by the most recent token-generation throughput from benchmarks.
29    LatestTgTps,
30}
31
32/// Direction for sorting.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum SortOrder {
36    /// Largest / most-recent first.
37    #[default]
38    Desc,
39    /// Smallest / oldest first.
40    Asc,
41}
42
43// ─────────────────────────────────────────────────────────────────────────────
44// Query struct
45// ─────────────────────────────────────────────────────────────────────────────
46
47/// Complete filter + sort specification for the model list.
48///
49/// All filter fields are optional — absent means "no constraint applied".
50/// `sort_by` and `order` always have sensible defaults (`AddedAt`, `Desc`).
51///
52/// This struct is used in three contexts:
53/// 1. **HTTP handler**: `Query<ModelListQueryParams>` is converted here.
54/// 2. **CLI direct-mode**: CLI flags are parsed directly into this struct.
55/// 3. **CLI proxy-mode**: serialised as HTTP query parameters sent to the daemon.
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct ModelListQuery {
58    /// Field to sort results by.
59    #[serde(default)]
60    pub sort_by: ModelSortBy,
61    /// Sort direction.
62    #[serde(default)]
63    pub order: SortOrder,
64    /// Inclusive minimum parameter count in billions (`param_count_b >= min_params`).
65    pub min_params: Option<f64>,
66    /// Inclusive maximum parameter count in billions (`param_count_b <= max_params`).
67    pub max_params: Option<f64>,
68    /// Inclusive minimum context length.
69    pub min_context: Option<f64>,
70    /// Inclusive maximum context length.
71    pub max_context: Option<f64>,
72    /// Quantization allowlist. A model passes if its quantization matches
73    /// any listed value (case-sensitive). Models with no quantization are
74    /// excluded when this filter is active.
75    pub quantizations: Option<Vec<String>>,
76    /// Required tags. A model passes only if it has **all** listed tags.
77    pub tags: Option<Vec<String>>,
78    /// Inclusive minimum `latest_tg_tps`. Models with no benchmark data are
79    /// **excluded** when either speed bound is set.
80    pub min_speed: Option<f64>,
81    /// Inclusive maximum `latest_tg_tps`. Models with no benchmark data are
82    /// **excluded** when either speed bound is set.
83    pub max_speed: Option<f64>,
84}
85
86// ─────────────────────────────────────────────────────────────────────────────
87// apply_query
88// ─────────────────────────────────────────────────────────────────────────────
89
90/// Apply a [`ModelListQuery`] to a model list, returning a filtered and sorted
91/// copy.
92///
93/// ## Filter rules
94///
95/// - **`min_params`/`max_params`**: model's `param_count_b` must fall within
96///   the range (inclusive).
97/// - **`min_context`/`max_context`**: model's `context_length` must fall
98///   within the range when it is set. Models without a context length are
99///   **not** excluded by a context range filter.
100/// - **`quantizations`**: model's quantization must match one of the listed
101///   values. Models with no quantization are excluded when this filter is
102///   active.
103/// - **`tags`**: model must have *all* listed tags (AND semantics).
104/// - **`min_speed`/`max_speed`**: model's `benchmark_summary.latest_tg_tps`
105///   must be within the range. Models with no benchmark data are **excluded**
106///   when either speed bound is active.
107///
108/// ## Sort behaviour
109///
110/// Default: `AddedAt Desc` (most recently added first). When sorting by
111/// `LatestTgTps`, models without benchmark data sort **last** in both
112/// ascending and descending orders.
113#[must_use]
114pub fn apply_query(mut models: Vec<Model>, query: &ModelListQuery) -> Vec<Model> {
115    models.retain(|m| matches_query(m, query));
116
117    match (query.sort_by, query.order) {
118        (ModelSortBy::Name, SortOrder::Asc) => {
119            models.sort_by(|a, b| a.name.cmp(&b.name));
120        }
121        (ModelSortBy::Name, SortOrder::Desc) => {
122            models.sort_by(|a, b| b.name.cmp(&a.name));
123        }
124        (ModelSortBy::ParamCount, SortOrder::Asc) => {
125            models.sort_by(|a, b| {
126                a.param_count_b
127                    .partial_cmp(&b.param_count_b)
128                    .unwrap_or(std::cmp::Ordering::Equal)
129            });
130        }
131        (ModelSortBy::ParamCount, SortOrder::Desc) => {
132            models.sort_by(|a, b| {
133                b.param_count_b
134                    .partial_cmp(&a.param_count_b)
135                    .unwrap_or(std::cmp::Ordering::Equal)
136            });
137        }
138        (ModelSortBy::LatestTgTps, SortOrder::Asc) => {
139            models.sort_by(|a, b| cmp_tps_asc(tps(a), tps(b)));
140        }
141        (ModelSortBy::LatestTgTps, SortOrder::Desc) => {
142            models.sort_by(|a, b| cmp_tps_desc(tps(a), tps(b)));
143        }
144        (ModelSortBy::AddedAt, SortOrder::Asc) => {
145            models.sort_by_key(|a| a.added_at);
146        }
147        (ModelSortBy::AddedAt, SortOrder::Desc) => {
148            models.sort_by_key(|b| std::cmp::Reverse(b.added_at));
149        }
150    }
151
152    models
153}
154
155/// Returns `true` when `model` satisfies all active filter constraints.
156fn matches_query(m: &Model, query: &ModelListQuery) -> bool {
157    // Param range
158    if let Some(min) = query.min_params {
159        if m.param_count_b < min {
160            return false;
161        }
162    }
163    if let Some(max) = query.max_params {
164        if m.param_count_b > max {
165            return false;
166        }
167    }
168
169    // Context range — models without a context length pass through
170    if let Some(ctx) = m.context_length {
171        #[allow(clippy::cast_precision_loss)]
172        let ctx_f = ctx as f64;
173        if let Some(min) = query.min_context {
174            if ctx_f < min {
175                return false;
176            }
177        }
178        if let Some(max) = query.max_context {
179            if ctx_f > max {
180                return false;
181            }
182        }
183    }
184
185    // Quantization allowlist
186    if let Some(quants) = &query.quantizations {
187        if !quants.is_empty() {
188            match &m.quantization {
189                Some(q) if quants.contains(q) => {}
190                _ => return false,
191            }
192        }
193    }
194
195    // Tags — model must carry ALL listed tags
196    if let Some(tags) = &query.tags {
197        if !tags.is_empty() && !tags.iter().all(|t| m.tags.contains(t)) {
198            return false;
199        }
200    }
201
202    // Speed filter — models without benchmark data are excluded when active
203    let speed_active = query.min_speed.is_some() || query.max_speed.is_some();
204    if speed_active {
205        match m.benchmark_summary.as_ref().and_then(|s| s.latest_tg_tps) {
206            None => return false,
207            Some(v) => {
208                if query.min_speed.is_some_and(|min| v < min) {
209                    return false;
210                }
211                if query.max_speed.is_some_and(|max| v > max) {
212                    return false;
213                }
214            }
215        }
216    }
217
218    true
219}
220
221/// Extract the latest TPS from a model's benchmark summary.
222fn tps(m: &Model) -> Option<f64> {
223    m.benchmark_summary.as_ref()?.latest_tg_tps
224}
225
226/// Compare optional TPS values ascending; `None` sorts last.
227fn cmp_tps_asc(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
228    match (a, b) {
229        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal),
230        (None, None) => std::cmp::Ordering::Equal,
231        (None, Some(_)) => std::cmp::Ordering::Greater,
232        (Some(_), None) => std::cmp::Ordering::Less,
233    }
234}
235
236/// Compare optional TPS values descending; `None` sorts last.
237fn cmp_tps_desc(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
238    match (a, b) {
239        (Some(a), Some(b)) => b.partial_cmp(&a).unwrap_or(std::cmp::Ordering::Equal),
240        (None, None) => std::cmp::Ordering::Equal,
241        (None, Some(_)) => std::cmp::Ordering::Greater,
242        (Some(_), None) => std::cmp::Ordering::Less,
243    }
244}
245
246// ─────────────────────────────────────────────────────────────────────────────
247// Tests
248// ─────────────────────────────────────────────────────────────────────────────
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::ModelCapabilities;
254    use crate::domain::benchmark::ModelBenchmarkSummary;
255    use chrono::Utc;
256    use std::collections::HashMap;
257    use std::path::PathBuf;
258
259    fn make_model(id: i64, name: &str, params: f64) -> Model {
260        Model {
261            id,
262            name: name.to_string(),
263            model_key: String::new(),
264            file_path: PathBuf::from(format!("/models/{name}.gguf")),
265            param_count_b: params,
266            architecture: None,
267            quantization: None,
268            context_length: None,
269            expert_count: None,
270            expert_used_count: None,
271            expert_shared_count: None,
272            metadata: HashMap::new(),
273            added_at: Utc::now(),
274            hf_repo_id: None,
275            hf_commit_sha: None,
276            hf_filename: None,
277            download_date: None,
278            last_update_check: None,
279            tags: vec![],
280            capabilities: ModelCapabilities::default(),
281            inference_defaults: None,
282            defaults_origin: None,
283            server_defaults: None,
284            benchmark_summary: None,
285        }
286    }
287
288    fn with_quant(mut m: Model, quant: &str) -> Model {
289        m.quantization = Some(quant.to_string());
290        m
291    }
292
293    fn with_tags(mut m: Model, tags: &[&str]) -> Model {
294        m.tags = tags.iter().map(ToString::to_string).collect();
295        m
296    }
297
298    fn with_tps(mut m: Model, tps: f64) -> Model {
299        use chrono::Utc;
300        m.benchmark_summary = Some(ModelBenchmarkSummary {
301            model_id: m.id,
302            best_tg_tps: Some(tps),
303            best_pp_tps: None,
304            latest_tg_tps: Some(tps),
305            latest_pp_tps: None,
306            latest_backend: None,
307            perf_run_count: 1,
308            compare_run_count: 0,
309            last_benchmarked_at: Utc::now(),
310            updated_at: Utc::now(),
311        });
312        m
313    }
314
315    fn models() -> Vec<Model> {
316        vec![
317            with_quant(make_model(1, "alpha", 7.0), "Q4_K_M"),
318            with_quant(make_model(2, "beta", 13.0), "Q8_0"),
319            with_quant(make_model(3, "gamma", 70.0), "Q4_K_M"),
320        ]
321    }
322
323    #[test]
324    fn default_query_preserves_all_models() {
325        let result = apply_query(models(), &ModelListQuery::default());
326        assert_eq!(result.len(), 3);
327    }
328
329    #[test]
330    fn param_range_filters_correctly() {
331        let query = ModelListQuery {
332            min_params: Some(8.0),
333            max_params: Some(20.0),
334            ..Default::default()
335        };
336        let result = apply_query(models(), &query);
337        assert_eq!(result.len(), 1);
338        assert_eq!(result[0].name, "beta");
339    }
340
341    #[test]
342    fn quantization_filter_keeps_matching() {
343        let query = ModelListQuery {
344            quantizations: Some(vec!["Q4_K_M".to_string()]),
345            ..Default::default()
346        };
347        let result = apply_query(models(), &query);
348        assert_eq!(result.len(), 2);
349        assert!(
350            result
351                .iter()
352                .all(|m| m.quantization.as_deref() == Some("Q4_K_M"))
353        );
354    }
355
356    #[test]
357    fn tag_filter_uses_and_semantics() {
358        let tagged = vec![
359            with_tags(make_model(1, "a", 7.0), &["chat", "code"]),
360            with_tags(make_model(2, "b", 13.0), &["chat"]),
361            with_tags(make_model(3, "c", 70.0), &["code"]),
362        ];
363        let query = ModelListQuery {
364            tags: Some(vec!["chat".to_string(), "code".to_string()]),
365            ..Default::default()
366        };
367        let result = apply_query(tagged, &query);
368        assert_eq!(result.len(), 1);
369        assert_eq!(result[0].name, "a");
370    }
371
372    #[test]
373    fn speed_filter_excludes_models_without_benchmark() {
374        let ms = vec![
375            with_tps(make_model(1, "fast", 7.0), 80.0),
376            make_model(2, "no-bench", 13.0), // no benchmark
377            with_tps(make_model(3, "slow", 70.0), 10.0),
378        ];
379        let query = ModelListQuery {
380            min_speed: Some(50.0),
381            ..Default::default()
382        };
383        let result = apply_query(ms, &query);
384        assert_eq!(result.len(), 1);
385        assert_eq!(result[0].name, "fast");
386    }
387
388    #[test]
389    fn sort_by_name_asc() {
390        let query = ModelListQuery {
391            sort_by: ModelSortBy::Name,
392            order: SortOrder::Asc,
393            ..Default::default()
394        };
395        let result = apply_query(models(), &query);
396        assert_eq!(result[0].name, "alpha");
397        assert_eq!(result[1].name, "beta");
398        assert_eq!(result[2].name, "gamma");
399    }
400
401    #[test]
402    fn sort_by_tps_desc_puts_none_last() {
403        let ms = vec![
404            with_tps(make_model(1, "fast", 7.0), 80.0),
405            make_model(2, "no-bench", 13.0),
406            with_tps(make_model(3, "slow", 70.0), 10.0),
407        ];
408        let query = ModelListQuery {
409            sort_by: ModelSortBy::LatestTgTps,
410            order: SortOrder::Desc,
411            ..Default::default()
412        };
413        let result = apply_query(ms, &query);
414        assert_eq!(result[0].name, "fast");
415        assert_eq!(result[1].name, "slow");
416        assert_eq!(result[2].name, "no-bench");
417    }
418
419    #[test]
420    fn empty_quantizations_vec_passes_all() {
421        let query = ModelListQuery {
422            quantizations: Some(vec![]),
423            ..Default::default()
424        };
425        let result = apply_query(models(), &query);
426        assert_eq!(result.len(), 3);
427    }
428}