Skip to main content

cherenkov_model_data/
discovery.rs

1//! Store-defined model discovery, separate from reading tensor bytes.
2//!
3//! Providers advertise their filters. Common result metadata is optional and
4//! does not determine execution compatibility or replace a checkpoint inventory.
5//!
6//! Adapters declare parameters through [`SearchSchema`] and implement
7//! [`StoreDiscovery`]. Callers use [`Discovery`] to validate requests, attach store
8//! identities, and follow continuation pages:
9//!
10//! ```
11//! use cherenkov_model_data::discovery::*;
12//! use serde_json::json;
13//!
14//! fn browse(adapter: &dyn StoreDiscovery, id: StoreId) -> anyhow::Result<()> {
15//!     let store = Discovery::new(id, adapter);
16//!     let mut query = SearchQuery {
17//!         text: None,
18//!         filters: vec![Filter {
19//!             field: "author".to_owned(),
20//!             operator: FilterOperator::Equal,
21//!             value: json!("example-team"),
22//!         }],
23//!         page: PageRequest { limit: 20, cursor: None },
24//!     };
25//!
26//!     loop {
27//!         let page = store.search(&query)?;
28//!         for item in page.items {
29//!             println!("{}: {}", item.store.0, item.candidate.reference);
30//!         }
31//!         query.page.cursor = page.next_cursor;
32//!         if query.page.cursor.is_none() {
33//!             return Ok(());
34//!         }
35//!     }
36//! }
37//! ```
38//!
39//! An empty page can have a continuation. Keep the query and page size unchanged
40//! and pass the cursor back verbatim. Adapters remain responsible for native
41//! cursor validity and for returning candidates that satisfy every predicate.
42
43use anyhow::{Result, bail, ensure};
44use serde::{Deserialize, Serialize};
45use serde_json::Value;
46use std::collections::BTreeMap;
47
48mod dispatch;
49mod query;
50pub use dispatch::{Discovery, StoreId, StoredCandidate, ValidatedPage, ValidatedSearch};
51
52/// Discovery operations supported by a store.
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct DiscoveryCapabilities {
55    /// Maximum page size for walking the inventory, absent when unsupported.
56    pub enumeration_max_page_size: Option<usize>,
57    /// Search fields and limits, absent when search is unsupported.
58    pub search: Option<SearchSchema>,
59}
60
61/// Optional discovery operations exposed by a file-store adapter.
62/// Discovery returns candidates without registering models or fetching weights.
63/// Methods are synchronous; search and enumeration may block on I/O.
64/// Advertise only operations and filter semantics the adapter can honor.
65pub trait StoreDiscovery {
66    /// Describe supported operations and provider-specific filters without I/O.
67    fn capabilities(&self) -> DiscoveryCapabilities;
68
69    /// Search one page after [`Discovery`] validates the request.
70    /// Implement text and filter semantics, combining predicates with AND.
71    /// Unknown metadata must not be treated as a matching value, even for `NotEqual`.
72    /// Report known omissions from missing filter metadata in [`SearchPage::gaps`].
73    /// The supplied cursor is provider-native. Validate it before using it; the
74    /// dispatcher's envelope checks do not authenticate or validate its contents.
75    fn search(&self, _query: ValidatedSearch<'_>) -> Result<SearchPage> {
76        bail!("this store does not support search")
77    }
78
79    /// Enumerate one page without a search predicate. A searchable store need not
80    /// support enumeration; registering it must not initiate a full crawl.
81    /// [`Discovery`] checks the advertised enumeration limit before calling this.
82    /// Validate the native cursor and return `None` when there are no further pages.
83    fn enumerate(&self, _page: ValidatedPage<'_>) -> Result<SearchPage> {
84        bail!("this store does not support enumeration")
85    }
86}
87
88/// Provider-defined filter fields and search limits, also usable to generate help.
89/// For example, an adapter can expose an exact publishing-account filter:
90///
91/// ```
92/// use cherenkov_model_data::discovery::*;
93/// use std::collections::BTreeMap;
94///
95/// let schema = SearchSchema {
96///     text_search: false,
97///     max_page_size: 100,
98///     fields: BTreeMap::from([("author".to_owned(), SearchField {
99///         description: "Publishing account or organization".to_owned(),
100///         value_type: ValueType::String,
101///         operators: vec![FilterOperator::Equal],
102///     })]),
103/// };
104/// ```
105///
106/// Return this schema in [`DiscoveryCapabilities::search`]. Field types and
107/// operators validate operands; the adapter implements their matching behavior.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct SearchSchema {
110    /// Whether the provider accepts free-text queries.
111    pub text_search: bool,
112    /// Maximum number of results requested in one page.
113    pub max_page_size: usize,
114    /// Filter definitions keyed by the provider's field names, such as `author`.
115    pub fields: BTreeMap<String, SearchField>,
116}
117
118/// Accepted operands and operations for one provider-specific search field.
119/// Advertising a field does not imply every candidate has a known value for it.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct SearchField {
122    /// Short explanation for CLI help, including units where relevant.
123    pub description: String,
124    /// Type of a filter operand, independent of the provider's stored representation.
125    pub value_type: ValueType,
126    /// Operations the provider supports for this field.
127    pub operators: Vec<FilterOperator>,
128}
129
130/// JSON value types accepted as filter operands; values are never coerced.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(tag = "kind", rename_all = "snake_case")]
133pub enum ValueType {
134    /// Text, including provider-defined identifiers and tags.
135    String,
136    /// A JSON boolean.
137    Boolean,
138    /// A signed or unsigned JSON integer, preserving its exact value.
139    Integer,
140    /// A nonnegative JSON integer, suitable for byte and parameter counts.
141    Unsigned,
142    /// A JSON number, including fractional values.
143    Number,
144    /// One of a provider's advertised string values.
145    Choice {
146        /// Case-sensitive accepted values.
147        values: Vec<String>,
148    },
149}
150
151/// Predicate operations; providers advertise only those they can honor.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum FilterOperator {
155    /// Match an equal value.
156    Equal,
157    /// Match a known value that differs from the operand.
158    NotEqual,
159    /// Match a smaller value.
160    Less,
161    /// Match a value no greater than the operand.
162    LessOrEqual,
163    /// Match a larger value.
164    Greater,
165    /// Match a value no smaller than the operand.
166    GreaterOrEqual,
167    /// Provider-defined containment, such as a tag or substring match.
168    Contains,
169    /// Match any operand in a nonempty array of the field's advertised value type.
170    AnyOf,
171}
172
173/// One predicate using a field from the selected store's search schema.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct Filter {
177    /// Provider-defined field key.
178    pub field: String,
179    /// Operation advertised for this field.
180    pub operator: FilterOperator,
181    /// Typed JSON operand, or an array of operands for [`FilterOperator::AnyOf`].
182    pub value: Value,
183}
184
185/// Bounded page request shared by search and inventory enumeration.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(deny_unknown_fields)]
188pub struct PageRequest {
189    /// Maximum results to return, greater than zero.
190    pub limit: usize,
191    /// Continuation from [`Discovery`] for the same store and unchanged request.
192    /// Adapters receive their native cursor after the dispatcher checks its scope.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub cursor: Option<String>,
195}
196
197impl PageRequest {
198    /// Check the page size against the store's limit.
199    pub fn validate(&self, maximum: usize) -> Result<()> {
200        ensure!(
201            self.limit > 0 && self.limit <= maximum,
202            "page limit must be 1..={maximum}"
203        );
204
205        Ok(())
206    }
207}
208
209/// Search text and predicates, all of which must match for a result to qualify.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct SearchQuery {
213    /// Free-text query, if the store supports one.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub text: Option<String>,
216    /// Provider-defined filters combined with AND.
217    #[serde(default)]
218    pub filters: Vec<Filter>,
219    /// Result bound and continuation state.
220    pub page: PageRequest,
221}
222
223/// A discovered candidate; the locator remains meaningful to its originating store.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ModelCandidate {
226    /// Store-defined source locator that registration can resolve.
227    /// A discovered branch or tag is not an immutable model identity.
228    pub reference: String,
229    /// Common descriptive facts, populated only when known.
230    #[serde(default)]
231    pub metadata: ModelMetadata,
232    /// Provider-specific metadata. Missing or null values mean unknown.
233    /// These keys need not coincide with the provider's search field names.
234    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
235    pub fields: BTreeMap<String, Value>,
236}
237
238/// Optional descriptive metadata shared by discovery results.
239/// Absence means unknown, rather than an empty string, zero, or a negative claim.
240#[derive(Debug, Clone, Default, Serialize, Deserialize)]
241pub struct ModelMetadata {
242    /// Publishing account or organization, not necessarily the model's author.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub publisher: Option<String>,
245    /// Provider-supplied model description.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub description: Option<String>,
248    /// Declared architecture name, without implying engine support.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub architecture: Option<String>,
251    /// Reported license identifiers; absence makes no claim about licensing.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub licenses: Option<Vec<String>>,
254    /// Total parameter count, including inactive MoE experts when applicable.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub parameters: Option<u64>,
257}
258
259/// Missing metadata that prevented evaluating a supported filter for candidates.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct MetadataGap {
262    /// Provider-defined filter field with missing values.
263    pub field: String,
264    /// Candidates omitted while producing this page, if the provider can count them.
265    /// Counts for different fields may refer to the same candidates.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub candidates: Option<u64>,
268}
269
270/// One result page. An empty page can still carry a continuation cursor.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct SearchPage<T = ModelCandidate> {
273    /// Matching candidates, no more than the requested page limit.
274    pub items: Vec<T>,
275    /// Continuation for the same store and query; absence means no further page.
276    pub next_cursor: Option<String>,
277    /// Total matches across pages when known, not the number of items in this page.
278    pub total: Option<u64>,
279    /// Known gaps in filter metadata. Absence does not guarantee provider completeness.
280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
281    pub gaps: Vec<MetadataGap>,
282}
283
284impl<T> Default for SearchPage<T> {
285    fn default() -> Self {
286        Self {
287            items: Vec::new(),
288            next_cursor: None,
289            total: None,
290            gaps: Vec::new(),
291        }
292    }
293}