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