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}