1mod canonical;
6mod validate;
7
8pub use validate::{
9 ALLOWED_DTYPES, ALLOWED_INDEX_TYPES, ALLOWED_RERANK_POLICIES, ALLOWED_SCORE_TYPES,
10 SUPPORTED_DTYPE, SUPPORTED_INDEX_TYPE, SUPPORTED_METRIC, SUPPORTED_NORMALIZE,
11 SUPPORTED_RERANK_POLICY, SUPPORTED_SCORE_TYPE,
12};
13
14use crate::layout::{URNA_FORMAT_VERSION, URNA_SCHEMA_VERSION};
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17
18#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31pub struct Capabilities {
32 pub supports_exact: bool,
33 pub supports_ann: bool,
34 pub supports_bm25: bool,
35 pub supports_citations: bool,
36 pub supports_reproducible_build: bool,
37}
38
39impl Default for Capabilities {
40 fn default() -> Self {
41 Self {
42 supports_exact: true,
43 supports_ann: false,
44 supports_bm25: false,
45 supports_citations: true,
46 supports_reproducible_build: true,
47 }
48 }
49}
50
51#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
59pub struct CapabilitiesExt {
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub supports_multimodal: Option<bool>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub graph_present: Option<bool>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub graph_entities_present: Option<bool>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub supports_catalog: Option<bool>,
68 #[serde(skip_serializing_if = "Option::is_none")]
72 pub blobs_present: Option<bool>,
73}
74
75#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
80pub struct Manifest {
81 pub format_version: u32,
82 pub schema_version: u32,
83 pub embedding_model: String,
84 pub embedding_dim: u32,
85 pub n_chunks: u64,
86 pub dtype: String,
87 pub metric: String,
88 pub score_type: String,
89 pub normalize: String,
90 pub index_type: String,
91 pub rerank_policy: String,
92 pub model_hash: String,
93 pub chunker_version: String,
94 pub capabilities: Capabilities,
95
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub title: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub version: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub created: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub description: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub authors: Option<Vec<String>>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub license: Option<String>,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
121 pub mrl_dim: Option<u32>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub full_dim: Option<u32>,
124
125 #[serde(skip_serializing_if = "Option::is_none")]
129 pub capabilities_ext: Option<CapabilitiesExt>,
130
131 #[serde(flatten)]
132 pub extra: BTreeMap<String, serde_json::Value>,
133}
134
135impl Default for Manifest {
136 fn default() -> Self {
137 Self {
138 format_version: URNA_FORMAT_VERSION,
139 schema_version: URNA_SCHEMA_VERSION,
140 embedding_model: String::new(),
141 embedding_dim: 0,
142 n_chunks: 0,
143 dtype: SUPPORTED_DTYPE.to_string(),
144 metric: SUPPORTED_METRIC.to_string(),
145 score_type: SUPPORTED_SCORE_TYPE.to_string(),
146 normalize: SUPPORTED_NORMALIZE.to_string(),
147 index_type: SUPPORTED_INDEX_TYPE.to_string(),
148 rerank_policy: SUPPORTED_RERANK_POLICY.to_string(),
149 model_hash: String::new(),
150 chunker_version: String::new(),
151 capabilities: Capabilities::default(),
152 title: None,
153 version: None,
154 created: None,
155 description: None,
156 authors: None,
157 license: None,
158 mrl_dim: None,
159 full_dim: None,
160 capabilities_ext: None,
161 extra: BTreeMap::new(),
162 }
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::error::UrnaError;
170
171 pub(super) fn good_manifest() -> Manifest {
172 Manifest {
173 embedding_model: "demo".into(),
174 embedding_dim: 4,
175 n_chunks: 1,
176 chunker_version: "demo-chunker/1".into(),
177 model_hash: format!("sha256:{}", "0".repeat(64)),
178 ..Default::default()
179 }
180 }
181
182 #[test]
183 fn default_is_invalid_until_filled() {
184 assert!(Manifest::default().validate().is_err());
185 }
186
187 #[test]
188 fn valid_manifest_passes() {
189 good_manifest().validate().unwrap();
190 }
191
192 #[test]
193 fn rejects_future_format_version() {
194 let mut m = good_manifest();
195 m.format_version = URNA_FORMAT_VERSION + 1;
196 assert!(matches!(
197 m.validate(),
198 Err(UrnaError::UnsupportedFormatVersion(_))
199 ));
200 }
201
202 #[test]
203 fn accepts_older_format_version() {
204 let mut m = good_manifest();
205 if URNA_FORMAT_VERSION > 0 {
206 m.format_version = URNA_FORMAT_VERSION - 1;
207 assert!(m.validate().is_ok());
208 }
209 }
210
211 #[test]
212 fn rejects_future_schema_version() {
213 let mut m = good_manifest();
214 m.schema_version = URNA_SCHEMA_VERSION + 1;
215 assert!(matches!(
216 m.validate(),
217 Err(UrnaError::UnsupportedSchemaVersion(_))
218 ));
219 }
220
221 #[test]
222 fn rejects_unsupported_dtype() {
223 let mut m = good_manifest();
224 m.dtype = "bfloat16".into();
225 assert!(matches!(m.validate(), Err(UrnaError::UnsupportedDType(_))));
226 }
227
228 #[test]
229 fn accepts_float16_and_int8_dtypes() {
230 for dt in ["float32", "float16", "int8"] {
231 let mut m = good_manifest();
232 m.dtype = dt.into();
233 m.validate()
234 .unwrap_or_else(|e| panic!("{} rejected: {}", dt, e));
235 }
236 }
237
238 #[test]
239 fn hnsw_requires_exact_rerank() {
240 let mut m = good_manifest();
241 m.index_type = "hnsw".into();
242 m.rerank_policy = "none".into();
243 m.capabilities.supports_ann = true;
244 assert!(matches!(m.validate(), Err(UrnaError::ManifestInvalid(_))));
245 m.rerank_policy = "exact".into();
246 m.validate().unwrap();
247 }
248
249 #[test]
250 fn hybrid_requires_bm25_capability() {
251 let mut m = good_manifest();
252 m.index_type = "hybrid".into();
253 m.rerank_policy = "exact".into();
254 m.score_type = "hybrid_rrf".into();
255 m.capabilities.supports_bm25 = false;
256 assert!(matches!(m.validate(), Err(UrnaError::ManifestInvalid(_))));
257 m.capabilities.supports_bm25 = true;
258 m.validate().unwrap();
259 }
260
261 #[test]
262 fn rejects_unsupported_metric() {
263 let mut m = good_manifest();
264 m.metric = "l2".into();
265 assert!(matches!(m.validate(), Err(UrnaError::UnsupportedMetric(_))));
266 }
267
268 #[test]
269 fn rejects_invalid_model_hash() {
270 let mut m = good_manifest();
271 m.model_hash = "deadbeef".into();
272 assert!(matches!(m.validate(), Err(UrnaError::InvalidModelHash(_))));
273 }
274
275 #[test]
276 fn rejects_missing_chunker_version() {
277 let mut m = good_manifest();
278 m.chunker_version = "".into();
279 assert!(matches!(m.validate(), Err(UrnaError::ManifestInvalid(_))));
280 }
281
282 #[test]
283 fn capabilities_required_for_v1() {
284 let mut m = good_manifest();
285 m.capabilities.supports_exact = false;
286 assert!(matches!(m.validate(), Err(UrnaError::ManifestInvalid(_))));
287 }
288
289 #[test]
290 fn canonical_json_is_deterministic() {
291 let m1 = good_manifest();
292 let m2 = good_manifest();
293 assert_eq!(
294 m1.to_canonical_json().unwrap(),
295 m2.to_canonical_json().unwrap()
296 );
297 }
298}