/home/noah/src/realizar/src/uri.rs
Line | Count | Source |
1 | | //! Pacha URI scheme support for model loading |
2 | | //! |
3 | | //! Enables direct model loading from the Pacha registry using URIs like: |
4 | | //! - `pacha://model-name:version` |
5 | | //! - `pacha://model-name` (latest version) |
6 | | //! |
7 | | //! ## Example |
8 | | //! |
9 | | //! ```rust,ignore |
10 | | //! use realizar::uri::{PachaUri, resolve_model_uri}; |
11 | | //! |
12 | | //! let uri = PachaUri::parse("pacha://llama-7b:1.0.0")?; |
13 | | //! let model_path = resolve_model_uri(&uri).await?; |
14 | | //! ``` |
15 | | //! |
16 | | //! ## Integration |
17 | | //! |
18 | | //! The Pacha URI scheme integrates with: |
19 | | //! - Model registry for automatic metadata retrieval |
20 | | //! - Lineage tracking for inference metrics |
21 | | //! - Content addressing for integrity verification |
22 | | |
23 | | #[cfg(feature = "registry")] |
24 | | use std::path::PathBuf; |
25 | | |
26 | | use crate::error::{RealizarError, Result}; |
27 | | |
28 | | /// Parsed Pacha URI |
29 | | /// |
30 | | /// Represents a `pacha://model:version` URI for model loading. |
31 | | #[derive(Debug, Clone, PartialEq, Eq)] |
32 | | pub struct PachaUri { |
33 | | /// Model name/identifier |
34 | | pub model: String, |
35 | | /// Version (None means latest) |
36 | | pub version: Option<String>, |
37 | | } |
38 | | |
39 | | impl PachaUri { |
40 | | /// Parse a Pacha URI string |
41 | | /// |
42 | | /// Supported formats: |
43 | | /// - `pacha://model-name:version` |
44 | | /// - `pacha://model-name` (latest version) |
45 | | /// |
46 | | /// # Errors |
47 | | /// |
48 | | /// Returns error if URI is malformed or uses unsupported scheme |
49 | 21 | pub fn parse(uri: &str) -> Result<Self> { |
50 | | // Check for pacha:// scheme |
51 | 21 | let path20 = uri |
52 | 21 | .strip_prefix("pacha://") |
53 | 21 | .ok_or_else(|| RealizarError::InvalidUri(format!1 ("Expected pacha:// scheme: {uri}"1 )))?1 ; |
54 | | |
55 | 20 | if path.is_empty() { |
56 | 2 | return Err(RealizarError::InvalidUri( |
57 | 2 | "Model name required after pacha://".to_string(), |
58 | 2 | )); |
59 | 18 | } |
60 | | |
61 | | // Split model:version |
62 | 18 | let (model16 , version16 ) = if let Some(colon_pos13 ) = path.rfind(':') { |
63 | 13 | let model = &path[..colon_pos]; |
64 | 13 | let version = &path[colon_pos + 1..]; |
65 | | |
66 | 13 | if model.is_empty() { |
67 | 1 | return Err(RealizarError::InvalidUri( |
68 | 1 | "Model name cannot be empty".to_string(), |
69 | 1 | )); |
70 | 12 | } |
71 | 12 | if version.is_empty() { |
72 | 1 | return Err(RealizarError::InvalidUri( |
73 | 1 | "Version cannot be empty after colon".to_string(), |
74 | 1 | )); |
75 | 11 | } |
76 | | |
77 | 11 | (model.to_string(), Some(version.to_string())) |
78 | | } else { |
79 | 5 | (path.to_string(), None) |
80 | | }; |
81 | | |
82 | 16 | Ok(Self { model, version }) |
83 | 21 | } |
84 | | |
85 | | /// Check if this is a Pacha URI |
86 | | #[must_use] |
87 | 4 | pub fn is_pacha_uri(uri: &str) -> bool { |
88 | 4 | uri.starts_with("pacha://") |
89 | 4 | } |
90 | | |
91 | | /// Convert back to URI string |
92 | | #[must_use] |
93 | 8 | pub fn to_uri_string(&self) -> String { |
94 | 8 | match &self.version { |
95 | 5 | Some(v) => format!("pacha://{}:{v}", self.model), |
96 | 3 | None => format!("pacha://{}", self.model), |
97 | | } |
98 | 8 | } |
99 | | |
100 | | /// Get the version or "latest" if none specified |
101 | | #[must_use] |
102 | 2 | pub fn version_or_latest(&self) -> &str { |
103 | 2 | self.version.as_deref().unwrap_or("latest") |
104 | 2 | } |
105 | | } |
106 | | |
107 | | impl std::fmt::Display for PachaUri { |
108 | 1 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
109 | 1 | write!(f, "{}", self.to_uri_string()) |
110 | 1 | } |
111 | | } |
112 | | |
113 | | /// Model metadata retrieved from Pacha registry |
114 | | #[derive(Debug, Clone)] |
115 | | #[cfg(feature = "registry")] |
116 | | pub struct ModelMetadata { |
117 | | /// Model name |
118 | | pub name: String, |
119 | | /// Model version |
120 | | pub version: String, |
121 | | /// Content hash (BLAKE3) |
122 | | pub content_hash: String, |
123 | | /// Signer public key (Ed25519) if signed |
124 | | pub signer_key: Option<String>, |
125 | | /// Local cached path |
126 | | pub local_path: PathBuf, |
127 | | } |
128 | | |
129 | | /// Resolver for Pacha URIs |
130 | | /// |
131 | | /// Handles model resolution from the Pacha registry. |
132 | | #[cfg(feature = "registry")] |
133 | | pub struct PachaResolver { |
134 | | registry: pacha::Registry, |
135 | | } |
136 | | |
137 | | #[cfg(feature = "registry")] |
138 | | impl PachaResolver { |
139 | | /// Create a new resolver with default registry |
140 | | /// |
141 | | /// # Errors |
142 | | /// |
143 | | /// Returns error if registry cannot be opened |
144 | | pub fn new() -> Result<Self> { |
145 | | let registry = pacha::Registry::open_default() |
146 | | .map_err(|e| RealizarError::RegistryError(format!("Failed to open registry: {e}")))?; |
147 | | Ok(Self { registry }) |
148 | | } |
149 | | |
150 | | /// Create a resolver with a specific registry |
151 | | #[must_use] |
152 | | pub fn with_registry(registry: pacha::Registry) -> Self { |
153 | | Self { registry } |
154 | | } |
155 | | |
156 | | /// Resolve a Pacha URI to model metadata and artifact data |
157 | | /// |
158 | | /// This function: |
159 | | /// 1. Parses the Pacha URI |
160 | | /// 2. Retrieves model from registry |
161 | | /// 3. Returns the metadata and artifact data |
162 | | /// |
163 | | /// # Errors |
164 | | /// |
165 | | /// Returns error if: |
166 | | /// - Model not found in registry |
167 | | /// - Retrieval fails |
168 | | pub fn resolve(&self, uri: &PachaUri) -> Result<(ModelMetadata, Vec<u8>)> { |
169 | | use pacha::model::ModelVersion; |
170 | | |
171 | | // Parse version |
172 | | let version = match &uri.version { |
173 | | Some(v) => ModelVersion::parse(v) |
174 | | .map_err(|e| RealizarError::RegistryError(format!("Invalid version: {e}")))?, |
175 | | None => ModelVersion::new(0, 0, 0), // Will get latest |
176 | | }; |
177 | | |
178 | | // Get model metadata from registry |
179 | | let model = self |
180 | | .registry |
181 | | .get_model(&uri.model, &version) |
182 | | .map_err(|e| RealizarError::ModelNotFound(format!("{}: {e}", uri.model)))?; |
183 | | |
184 | | // Get the artifact data |
185 | | let artifact = self |
186 | | .registry |
187 | | .get_model_artifact(&uri.model, &version) |
188 | | .map_err(|e| { |
189 | | RealizarError::RegistryError(format!("Failed to get model artifact: {e}")) |
190 | | })?; |
191 | | |
192 | | let metadata = ModelMetadata { |
193 | | name: uri.model.clone(), |
194 | | version: model.version.to_string(), |
195 | | content_hash: model.content_address.to_string(), |
196 | | signer_key: None, // Signing info retrieved separately via pacha::signing |
197 | | local_path: PathBuf::new(), // Artifact is returned directly, no file path |
198 | | }; |
199 | | |
200 | | Ok((metadata, artifact)) |
201 | | } |
202 | | |
203 | | /// Get model metadata without loading artifact |
204 | | /// |
205 | | /// # Errors |
206 | | /// |
207 | | /// Returns error if model not found |
208 | | pub fn get_metadata(&self, uri: &PachaUri) -> Result<ModelMetadata> { |
209 | | use pacha::model::ModelVersion; |
210 | | |
211 | | let version = match &uri.version { |
212 | | Some(v) => ModelVersion::parse(v) |
213 | | .map_err(|e| RealizarError::RegistryError(format!("Invalid version: {e}")))?, |
214 | | None => ModelVersion::new(0, 0, 0), |
215 | | }; |
216 | | |
217 | | let model = self |
218 | | .registry |
219 | | .get_model(&uri.model, &version) |
220 | | .map_err(|e| RealizarError::ModelNotFound(format!("{}: {e}", uri.model)))?; |
221 | | |
222 | | Ok(ModelMetadata { |
223 | | name: uri.model.clone(), |
224 | | version: model.version.to_string(), |
225 | | content_hash: model.content_address.to_string(), |
226 | | signer_key: None, // Signing info retrieved separately via pacha::signing |
227 | | local_path: PathBuf::new(), |
228 | | }) |
229 | | } |
230 | | } |
231 | | |
232 | | /// Lineage information for model tracing |
233 | | /// |
234 | | /// Propagated to inference metrics for observability. |
235 | | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] |
236 | | pub struct ModelLineage { |
237 | | /// Original Pacha URI |
238 | | pub uri: String, |
239 | | /// Model name |
240 | | pub model: String, |
241 | | /// Model version |
242 | | pub version: String, |
243 | | /// Content hash (BLAKE3) for integrity |
244 | | pub content_hash: String, |
245 | | /// Whether the model was verified (signature check) |
246 | | pub verified: bool, |
247 | | /// Timestamp when lineage was captured |
248 | | pub captured_at: u64, |
249 | | } |
250 | | |
251 | | impl ModelLineage { |
252 | | /// Create lineage from Pacha URI and metadata |
253 | | #[cfg(feature = "registry")] |
254 | | #[must_use] |
255 | | pub fn from_metadata(uri: &PachaUri, metadata: &ModelMetadata) -> Self { |
256 | | Self { |
257 | | uri: uri.to_uri_string(), |
258 | | model: metadata.name.clone(), |
259 | | version: metadata.version.clone(), |
260 | | content_hash: metadata.content_hash.clone(), |
261 | | verified: metadata.signer_key.is_some(), |
262 | | captured_at: std::time::SystemTime::now() |
263 | | .duration_since(std::time::UNIX_EPOCH) |
264 | | .map(|d| d.as_secs()) |
265 | | .unwrap_or(0), |
266 | | } |
267 | | } |
268 | | |
269 | | /// Create lineage without registry metadata |
270 | | #[must_use] |
271 | 5 | pub fn from_uri(uri: &PachaUri) -> Self { |
272 | | Self { |
273 | 5 | uri: uri.to_uri_string(), |
274 | 5 | model: uri.model.clone(), |
275 | 5 | version: uri.version.clone().unwrap_or_else(|| "unknown"2 .to_string2 ()), |
276 | 5 | content_hash: String::new(), |
277 | | verified: false, |
278 | 5 | captured_at: std::time::SystemTime::now() |
279 | 5 | .duration_since(std::time::UNIX_EPOCH) |
280 | 5 | .map(|d| d.as_secs()) |
281 | 5 | .unwrap_or(0), |
282 | | } |
283 | 5 | } |
284 | | } |
285 | | |
286 | | #[cfg(test)] |
287 | | mod tests { |
288 | | use super::*; |
289 | | |
290 | | #[test] |
291 | 1 | fn test_parse_full_uri() { |
292 | 1 | let uri = PachaUri::parse("pacha://llama-7b:1.0.0").expect("test"); |
293 | 1 | assert_eq!(uri.model, "llama-7b"); |
294 | 1 | assert_eq!(uri.version, Some("1.0.0".to_string())); |
295 | 1 | } |
296 | | |
297 | | #[test] |
298 | 1 | fn test_parse_uri_without_version() { |
299 | 1 | let uri = PachaUri::parse("pacha://mistral-7b").expect("test"); |
300 | 1 | assert_eq!(uri.model, "mistral-7b"); |
301 | 1 | assert_eq!(uri.version, None); |
302 | 1 | } |
303 | | |
304 | | #[test] |
305 | 1 | fn test_parse_uri_with_semver() { |
306 | 1 | let uri = PachaUri::parse("pacha://gpt2:2.0.0-beta.1").expect("test"); |
307 | 1 | assert_eq!(uri.model, "gpt2"); |
308 | 1 | assert_eq!(uri.version, Some("2.0.0-beta.1".to_string())); |
309 | 1 | } |
310 | | |
311 | | #[test] |
312 | 1 | fn test_parse_uri_with_org() { |
313 | 1 | let uri = PachaUri::parse("pacha://paiml/trueno-llm:1.0").expect("test"); |
314 | 1 | assert_eq!(uri.model, "paiml/trueno-llm"); |
315 | 1 | assert_eq!(uri.version, Some("1.0".to_string())); |
316 | 1 | } |
317 | | |
318 | | #[test] |
319 | 1 | fn test_invalid_scheme() { |
320 | 1 | let result = PachaUri::parse("http://example.com/model"); |
321 | 1 | assert!(result.is_err()); |
322 | 1 | } |
323 | | |
324 | | #[test] |
325 | 1 | fn test_empty_model() { |
326 | 1 | let result = PachaUri::parse("pacha://"); |
327 | 1 | assert!(result.is_err()); |
328 | 1 | } |
329 | | |
330 | | #[test] |
331 | 1 | fn test_empty_version() { |
332 | 1 | let result = PachaUri::parse("pacha://model:"); |
333 | 1 | assert!(result.is_err()); |
334 | 1 | } |
335 | | |
336 | | #[test] |
337 | 1 | fn test_is_pacha_uri() { |
338 | 1 | assert!(PachaUri::is_pacha_uri("pacha://model:1.0")); |
339 | 1 | assert!(PachaUri::is_pacha_uri("pacha://model")); |
340 | 1 | assert!(!PachaUri::is_pacha_uri("http://example.com")); |
341 | 1 | assert!(!PachaUri::is_pacha_uri("/path/to/model.gguf")); |
342 | 1 | } |
343 | | |
344 | | #[test] |
345 | 1 | fn test_to_uri_string() { |
346 | 1 | let uri = PachaUri::parse("pacha://model:1.0").expect("test"); |
347 | 1 | assert_eq!(uri.to_uri_string(), "pacha://model:1.0"); |
348 | | |
349 | 1 | let uri_no_version = PachaUri::parse("pacha://model").expect("test"); |
350 | 1 | assert_eq!(uri_no_version.to_uri_string(), "pacha://model"); |
351 | 1 | } |
352 | | |
353 | | #[test] |
354 | 1 | fn test_version_or_latest() { |
355 | 1 | let uri = PachaUri::parse("pacha://model:2.0").expect("test"); |
356 | 1 | assert_eq!(uri.version_or_latest(), "2.0"); |
357 | | |
358 | 1 | let uri_no_version = PachaUri::parse("pacha://model").expect("test"); |
359 | 1 | assert_eq!(uri_no_version.version_or_latest(), "latest"); |
360 | 1 | } |
361 | | |
362 | | #[test] |
363 | 1 | fn test_display() { |
364 | 1 | let uri = PachaUri::parse("pacha://llama:1.0.0").expect("test"); |
365 | 1 | assert_eq!(format!("{uri}"), "pacha://llama:1.0.0"); |
366 | 1 | } |
367 | | |
368 | | #[test] |
369 | 1 | fn test_lineage_from_uri() { |
370 | 1 | let uri = PachaUri::parse("pacha://model:1.0").expect("test"); |
371 | 1 | let lineage = ModelLineage::from_uri(&uri); |
372 | | |
373 | 1 | assert_eq!(lineage.uri, "pacha://model:1.0"); |
374 | 1 | assert_eq!(lineage.model, "model"); |
375 | 1 | assert_eq!(lineage.version, "1.0"); |
376 | 1 | assert!(!lineage.verified); |
377 | 1 | assert!(lineage.captured_at > 0); |
378 | 1 | } |
379 | | |
380 | | // ========================================================================= |
381 | | // Coverage Tests |
382 | | // ========================================================================= |
383 | | |
384 | | #[test] |
385 | 1 | fn test_pacha_uri_clone_and_eq() { |
386 | 1 | let uri1 = PachaUri::parse("pacha://model:1.0").expect("test"); |
387 | 1 | let uri2 = uri1.clone(); |
388 | 1 | assert_eq!(uri1, uri2); |
389 | 1 | } |
390 | | |
391 | | #[test] |
392 | 1 | fn test_lineage_from_uri_no_version() { |
393 | 1 | let uri = PachaUri::parse("pacha://model-name").expect("test"); |
394 | 1 | let lineage = ModelLineage::from_uri(&uri); |
395 | 1 | assert_eq!(lineage.version, "unknown"); |
396 | 1 | } |
397 | | |
398 | | #[test] |
399 | 1 | fn test_parse_empty_model_error_msg() { |
400 | 1 | let result = PachaUri::parse("pacha://"); |
401 | 1 | match result { |
402 | 1 | Err(e) => assert!(e.to_string().contains("required")), |
403 | 0 | Ok(_) => panic!("Expected error"), |
404 | | } |
405 | 1 | } |
406 | | |
407 | | #[test] |
408 | 1 | fn test_parse_empty_model_name_before_colon() { |
409 | 1 | let result = PachaUri::parse("pacha://:1.0"); |
410 | 1 | match result { |
411 | 1 | Err(e) => assert!(e.to_string().contains("empty")), |
412 | 0 | Ok(_) => panic!("Expected error"), |
413 | | } |
414 | 1 | } |
415 | | |
416 | | #[test] |
417 | 1 | fn test_lineage_content_hash_empty() { |
418 | 1 | let uri = PachaUri::parse("pacha://test").expect("test"); |
419 | 1 | let lineage = ModelLineage::from_uri(&uri); |
420 | 1 | assert!(lineage.content_hash.is_empty()); |
421 | 1 | } |
422 | | |
423 | | #[test] |
424 | 1 | fn test_model_lineage_serialize() { |
425 | 1 | let uri = PachaUri::parse("pacha://model:1.0").expect("test"); |
426 | 1 | let lineage = ModelLineage::from_uri(&uri); |
427 | 1 | let json = serde_json::to_string(&lineage).expect("test"); |
428 | 1 | assert!(json.contains("model")); |
429 | 1 | assert!(json.contains("1.0")); |
430 | 1 | } |
431 | | |
432 | | #[test] |
433 | 1 | fn test_model_lineage_deserialize() { |
434 | 1 | let json = r#"{"uri":"pacha://test:1.0","model":"test","version":"1.0","content_hash":"","verified":false,"captured_at":12345}"#; |
435 | 1 | let lineage: ModelLineage = serde_json::from_str(json).expect("test"); |
436 | 1 | assert_eq!(lineage.model, "test"); |
437 | 1 | assert_eq!(lineage.version, "1.0"); |
438 | 1 | } |
439 | | |
440 | | #[test] |
441 | 1 | fn test_pacha_uri_debug() { |
442 | 1 | let uri = PachaUri::parse("pacha://model:1.0").expect("test"); |
443 | 1 | let debug = format!("{:?}", uri); |
444 | 1 | assert!(debug.contains("PachaUri")); |
445 | 1 | assert!(debug.contains("model")); |
446 | 1 | } |
447 | | |
448 | | #[test] |
449 | 1 | fn test_model_lineage_debug() { |
450 | 1 | let uri = PachaUri::parse("pacha://model:1.0").expect("test"); |
451 | 1 | let lineage = ModelLineage::from_uri(&uri); |
452 | 1 | let debug = format!("{:?}", lineage); |
453 | 1 | assert!(debug.contains("ModelLineage")); |
454 | 1 | } |
455 | | } |