Skip to main content

urna_format/manifest/
mod.rs

1//! Manifest types and constants. The `validate` impl lives in
2//! `manifest::validate`; the canonical-JSON serializer in
3//! `manifest::canonical`. This module owns the data shape only.
4
5mod 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/// Capabilities advertised by a .urna file. v1 only requires `supports_exact`
19/// and `supports_reproducible_build` to be true; the rest are forward-looking
20/// flags so a runtime can decide what to do without reading every section.
21///
22/// ADDITIVITY RULE (frozen v1): this struct is plain non-optional bools, so
23/// a NEW required bool here is a deserialization break for old manifests
24/// (which lack the field) AND a file_hash break for every existing file
25/// (the JSON changes). NEVER add a required bool here. New capability flags
26/// go in `CapabilitiesExt` (an `Option` on the manifest, each flag an
27/// `Option<bool>` skipped when unset) or in the manifest `extra` map, both
28/// of which serialize to nothing when unused and so keep existing files
29/// byte-identical. The guard is `tests/manifest_additivity.rs`.
30#[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/// Forward-looking capability flags, the additive-safe home for everything
52/// past the v1 `Capabilities` bools. Every field is `Option<bool>` skipped
53/// when `None`, and the whole struct rides the manifest as an `Option`
54/// skipped when `None`, so a file that sets none of these serializes
55/// byte-identically to a v1 manifest. The named flags match the reserved
56/// section work (see docs/ARC.toml): a feature sets its
57/// flag only when it actually emits its sections.
58#[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    /// set when the file emits the blob pair (0x14 blob_refs, and 0x16
69    /// blob_span_overlay when spans point into blobs). gates the runtime
70    /// open, exactly like `graph_present` gates 0x0C.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub blobs_present: Option<bool>,
73}
74
75/// JCS-canonical manifest for a .urna file.
76///
77/// Field order on disk follows declaration order. Extra keys land in `extra`
78/// and are serialized in BTreeMap order; this keeps the JSON deterministic.
79#[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    /// Matryoshka disclosure metadata (additive optional). When a file is
110    /// built with prefix truncation, `mrl_dim` is the effective (stored)
111    /// embedding prefix dim and `full_dim` is the source model dim before
112    /// truncation; `embedding_dim` always equals the EFFECTIVE stride the
113    /// runtime reads. Unset on non-truncated files so they stay
114    /// byte-identical with a v1 manifest. content_hash is over the decoded
115    /// embeddings bytes, so a truncated file legitimately differs from
116    /// full-dim: citations are tied to a given mrl_dim, never claimed stable
117    /// across dims. Validity (`mrl_dim <= full_dim`, `full_dim ==
118    /// embedding_dim` only when truncation is off, etc) is checked in
119    /// `validate`.
120    #[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    /// Additive-safe home for capability flags past the v1 `Capabilities`
126    /// bools. `None` (the default) serializes to nothing, so existing files
127    /// stay byte-identical; set it only when a feature emits its sections.
128    #[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}