qualia_core_db/modalities/logic/
geometry_asset_shacl.rs1use crate::governance::webizen::SlgOpcode;
14use crate::q_hash;
15
16pub const P_LICENCE: u64 = crate::q_hash("geo:license");
18
19pub const MAX_GEOMETRY_COUNT: u32 = 1 << 22;
21
22#[derive(Debug, Clone)]
24pub struct GeometryAssetConfiguration {
25 pub max_vertex_count: u32,
26 pub max_triangle_count: u32,
27 pub allowed_source_formats: Vec<String>,
28 pub allowed_units: Vec<String>,
29 pub allowed_licences: Vec<String>,
30 pub sensitivity_ladder: Vec<String>,
32}
33
34impl Default for GeometryAssetConfiguration {
35 fn default() -> Self {
36 let owned = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect();
37 Self {
38 max_vertex_count: MAX_GEOMETRY_COUNT,
39 max_triangle_count: MAX_GEOMETRY_COUNT,
40 allowed_source_formats: owned(&["obj", "stl", "glb", "gltf"]),
41 allowed_units: owned(&["metre", "millimetre", "centimetre", "inch", "dimensionless"]),
42 allowed_licences: owned(&["CC0", "CC-BY", "CC-BY-SA", "ODC-PDDL", "MIT", "Apache-2.0"]),
43 sensitivity_ladder: owned(&["Public", "Restricted", "Classified", "Sanctuary"]),
44 }
45 }
46}
47
48impl GeometryAssetConfiguration {
49 pub fn to_opcodes(&self) -> Vec<SlgOpcode> {
52 let mut ops = vec![
53 SlgOpcode::CheckMinInclusive(1.0),
54 SlgOpcode::CheckMaxInclusive(self.max_vertex_count as f64),
55 SlgOpcode::CheckMaxInclusive(self.max_triangle_count as f64),
56 ];
57 for fmt in &self.allowed_source_formats {
58 ops.push(SlgOpcode::CheckHasValue(q_hash(fmt)));
59 }
60 for unit in &self.allowed_units {
61 ops.push(SlgOpcode::CheckHasValue(q_hash(unit)));
62 }
63 for licence in &self.allowed_licences {
64 ops.push(SlgOpcode::CheckHasValue(q_hash(licence)));
65 }
66 ops
67 }
68
69 fn sensitivity_rank(&self, class: &str) -> usize {
72 self.sensitivity_ladder
73 .iter()
74 .position(|c| c == class)
75 .unwrap_or(self.sensitivity_ladder.len().saturating_sub(1))
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct GeometryManifestFacts<'a> {
82 pub vertex_count: u32,
83 pub triangle_count: u32,
84 pub source_format: &'a str,
85 pub unit: &'a str,
86 pub bbox_min: [f32; 3],
87 pub bbox_max: [f32; 3],
88 pub max_triangle_index: Option<u32>,
90 pub claimed_compiled_digest: u32,
92 pub actual_container_crc32c: u32,
95 pub input_sensitivities: &'a [&'a str],
97 pub declared_sensitivity: Option<&'a str>,
99 pub licence: &'a str,
100 pub creator: Option<&'a str>,
101 pub valid_from: Option<u64>,
102 pub valid_until: Option<u64>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum GeometryConstraintViolation {
108 VertexCountOutOfRange {
109 count: u32,
110 max: u32,
111 },
112 TriangleCountOutOfRange {
113 count: u32,
114 max: u32,
115 },
116 UnknownSourceFormat(String),
117 UnknownUnit(String),
118 UnknownLicence(String),
119 NonFiniteBbox,
121 InvertedBbox {
123 axis: u8,
124 },
125 IndexOutOfBounds {
127 max_index: u32,
128 vertex_count: u32,
129 },
130 CompiledDigestMismatch {
133 claimed: u32,
134 actual: u32,
135 },
136 SensitivityDowngraded {
139 declared: String,
140 required: String,
141 },
142 TemporalValidityInverted {
144 from: u64,
145 until: u64,
146 },
147}
148
149pub fn validate_geometry_manifest(
152 facts: &GeometryManifestFacts,
153 cfg: &GeometryAssetConfiguration,
154) -> Vec<GeometryConstraintViolation> {
155 use GeometryConstraintViolation::*;
156 let mut v = Vec::new();
157
158 if facts.vertex_count < 1 || facts.vertex_count > cfg.max_vertex_count {
160 v.push(VertexCountOutOfRange {
161 count: facts.vertex_count,
162 max: cfg.max_vertex_count,
163 });
164 }
165 if facts.triangle_count < 1 || facts.triangle_count > cfg.max_triangle_count {
166 v.push(TriangleCountOutOfRange {
167 count: facts.triangle_count,
168 max: cfg.max_triangle_count,
169 });
170 }
171
172 if !cfg
174 .allowed_source_formats
175 .iter()
176 .any(|s| s == facts.source_format)
177 {
178 v.push(UnknownSourceFormat(facts.source_format.to_string()));
179 }
180 if !cfg.allowed_units.iter().any(|s| s == facts.unit) {
181 v.push(UnknownUnit(facts.unit.to_string()));
182 }
183 if !cfg.allowed_licences.iter().any(|s| s == facts.licence) {
184 v.push(UnknownLicence(facts.licence.to_string()));
185 }
186
187 let finite = facts
189 .bbox_min
190 .iter()
191 .chain(facts.bbox_max.iter())
192 .all(|x| x.is_finite());
193 if !finite {
194 v.push(NonFiniteBbox);
195 } else {
196 for axis in 0..3 {
197 if facts.bbox_min[axis] > facts.bbox_max[axis] {
198 v.push(InvertedBbox { axis: axis as u8 });
199 }
200 }
201 }
202
203 if let Some(max_index) = facts.max_triangle_index {
205 if max_index >= facts.vertex_count {
206 v.push(IndexOutOfBounds {
207 max_index,
208 vertex_count: facts.vertex_count,
209 });
210 }
211 }
212
213 if facts.claimed_compiled_digest != facts.actual_container_crc32c {
215 v.push(CompiledDigestMismatch {
216 claimed: facts.claimed_compiled_digest,
217 actual: facts.actual_container_crc32c,
218 });
219 }
220
221 if let Some(declared) = facts.declared_sensitivity {
223 if let Some(required) = facts
224 .input_sensitivities
225 .iter()
226 .max_by_key(|c| cfg.sensitivity_rank(c))
227 {
228 if cfg.sensitivity_rank(declared) < cfg.sensitivity_rank(required) {
229 v.push(SensitivityDowngraded {
230 declared: declared.to_string(),
231 required: required.to_string(),
232 });
233 }
234 }
235 }
236
237 if let (Some(from), Some(until)) = (facts.valid_from, facts.valid_until) {
239 if from > until {
240 v.push(TemporalValidityInverted { from, until });
241 }
242 }
243
244 v
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn valid_facts() -> GeometryManifestFacts<'static> {
252 GeometryManifestFacts {
253 vertex_count: 3,
254 triangle_count: 1,
255 source_format: "glb",
256 unit: "metre",
257 bbox_min: [0.0, 0.0, 0.0],
258 bbox_max: [1.0, 1.0, 1.0],
259 max_triangle_index: Some(2),
260 claimed_compiled_digest: 0xDEAD_BEEF,
261 actual_container_crc32c: 0xDEAD_BEEF,
262 input_sensitivities: &["Restricted"],
263 declared_sensitivity: Some("Classified"),
264 licence: "CC-BY",
265 creator: Some("did:qualia:test_creator"),
266 valid_from: Some(100),
267 valid_until: Some(200),
268 }
269 }
270
271 #[test]
272 fn a_well_formed_manifest_passes() {
273 assert!(
274 validate_geometry_manifest(&valid_facts(), &GeometryAssetConfiguration::default())
275 .is_empty()
276 );
277 }
278
279 #[test]
280 fn to_opcodes_emits_bounds_and_membership() {
281 let ops = GeometryAssetConfiguration::default().to_opcodes();
282 assert_eq!(ops.len(), 18);
284 assert!(ops.iter().any(
285 |o| matches!(o, SlgOpcode::CheckMaxInclusive(m) if *m == MAX_GEOMETRY_COUNT as f64)
286 ));
287 assert!(ops.contains(&SlgOpcode::CheckHasValue(q_hash("glb"))));
288 assert!(ops.contains(&SlgOpcode::CheckHasValue(q_hash("metre"))));
289 assert!(ops.contains(&SlgOpcode::CheckHasValue(q_hash("CC0"))));
290 }
291
292 #[test]
293 fn counts_out_of_range_are_caught() {
294 let cfg = GeometryAssetConfiguration::default();
295 let mut f = valid_facts();
296 f.vertex_count = 0;
297 f.triangle_count = MAX_GEOMETRY_COUNT + 1;
298 let v = validate_geometry_manifest(&f, &cfg);
299 assert!(
300 v.contains(&GeometryConstraintViolation::VertexCountOutOfRange {
301 count: 0,
302 max: MAX_GEOMETRY_COUNT
303 })
304 );
305 assert!(v.iter().any(|x| matches!(
306 x,
307 GeometryConstraintViolation::TriangleCountOutOfRange { .. }
308 )));
309 }
310
311 #[test]
312 fn unknown_format_and_unit_and_licence_are_caught() {
313 let cfg = GeometryAssetConfiguration::default();
314 let mut f = valid_facts();
315 f.source_format = "fbx";
316 f.unit = "cubits";
317 f.licence = "proprietary";
318 let v = validate_geometry_manifest(&f, &cfg);
319 assert!(
320 v.contains(&GeometryConstraintViolation::UnknownSourceFormat(
321 "fbx".to_string()
322 ))
323 );
324 assert!(v.contains(&GeometryConstraintViolation::UnknownUnit(
325 "cubits".to_string()
326 )));
327 assert!(v.contains(&GeometryConstraintViolation::UnknownLicence(
328 "proprietary".to_string()
329 )));
330 }
331
332 #[test]
333 fn inverted_and_nonfinite_bbox_are_caught() {
334 let cfg = GeometryAssetConfiguration::default();
335 let mut f = valid_facts();
336 f.bbox_min = [0.0, 5.0, 0.0]; f.bbox_max = [1.0, 1.0, 1.0];
338 assert!(validate_geometry_manifest(&f, &cfg)
339 .contains(&GeometryConstraintViolation::InvertedBbox { axis: 1 }));
340
341 let mut g = valid_facts();
342 g.bbox_max = [f32::NAN, 1.0, 1.0];
343 assert!(validate_geometry_manifest(&g, &cfg)
344 .contains(&GeometryConstraintViolation::NonFiniteBbox));
345 }
346
347 #[test]
348 fn out_of_bounds_index_is_caught() {
349 let cfg = GeometryAssetConfiguration::default();
350 let mut f = valid_facts();
351 f.max_triangle_index = Some(3); assert!(validate_geometry_manifest(&f, &cfg).contains(
353 &GeometryConstraintViolation::IndexOutOfBounds {
354 max_index: 3,
355 vertex_count: 3
356 }
357 ));
358 }
359
360 #[test]
361 fn a_manifest_that_lies_about_its_container_is_caught() {
362 let cfg = GeometryAssetConfiguration::default();
363 let mut f = valid_facts();
364 f.claimed_compiled_digest = 0x0000_0001; assert!(validate_geometry_manifest(&f, &cfg).contains(
366 &GeometryConstraintViolation::CompiledDigestMismatch {
367 claimed: 1,
368 actual: 0xDEAD_BEEF
369 }
370 ));
371 }
372
373 #[test]
374 fn sensitivity_cannot_be_downgraded_below_an_input() {
375 let cfg = GeometryAssetConfiguration::default();
376 let mut f = valid_facts();
377 f.input_sensitivities = &["Public", "Classified"]; f.declared_sensitivity = Some("Restricted"); assert!(validate_geometry_manifest(&f, &cfg).contains(
380 &GeometryConstraintViolation::SensitivityDowngraded {
381 declared: "Restricted".to_string(),
382 required: "Classified".to_string(),
383 }
384 ));
385 f.declared_sensitivity = Some("Sanctuary");
387 assert!(!validate_geometry_manifest(&f, &cfg)
388 .iter()
389 .any(|x| matches!(x, GeometryConstraintViolation::SensitivityDowngraded { .. })));
390 }
391
392 #[test]
393 fn inverted_temporal_validity_is_caught() {
394 let cfg = GeometryAssetConfiguration::default();
395 let mut f = valid_facts();
396 f.valid_from = Some(200);
397 f.valid_until = Some(100);
398 assert!(validate_geometry_manifest(&f, &cfg).contains(
399 &GeometryConstraintViolation::TemporalValidityInverted {
400 from: 200,
401 until: 100
402 }
403 ));
404 }
405}