Skip to main content

qualia_core_db/modalities/logic/
geometry_asset_shacl.rs

1//! Geometry-asset SHACL — the runtime half of `shapes/geometry-asset.shacl.ttl` (the normative
2//! declarative source; see `docs/manuals/standards/geometry-asset-ontology.md` §5).
3//!
4//! Two halves, honestly separated:
5//! 1. **Per-property bounds** → [`GeometryAssetConfiguration::to_opcodes`], SLG-VM opcodes matching the
6//!    `.ttl` `geo:MeshShape` (counts ≤ 2²², `sourceFormat`/`unit` ∈ an allowed set). This is the same
7//!    `Configuration → to_opcodes` pattern as `specialized_libs_shacl.rs`.
8//! 2. **Cross-property (relational) constraints** → [`validate_geometry_manifest`]. Plain per-property
9//!    SHACL cannot express "bbox not inverted", "every index < vertexCount", or "compiledDigest ==
10//!    the real `.10d` CRC" — they need computation over several facts at once. The `.ttl` explicitly
11//!    defers these to this shim; here they are real checks, not comments.
12
13use crate::governance::webizen::SlgOpcode;
14use crate::q_hash;
15
16/// Predicate hash for `geo:license` in the SHACL ontology.
17pub const P_LICENCE: u64 = crate::q_hash("geo:license");
18
19/// The `.10d` container's `MAX_VERTEX_COUNT` / `MAX_TRIANGLE_COUNT` (2²²) — the malicious-size guard.
20pub const MAX_GEOMETRY_COUNT: u32 = 1 << 22;
21
22/// `q42:GeometryAssetConfiguration` — the per-property bounds for a compiled geometry-asset manifest.
23#[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    /// Sensitivity classes, least→most restrictive (index = rank). Absent/unknown ⇒ most restrictive.
31    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    /// The per-property SHACL constraints as SLG-VM opcodes: `vertexCount`/`triangleCount` in
50    /// `[1, max]`, and each allowed `sourceFormat`/`unit` value as a `CheckHasValue` (the `sh:in` set).
51    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    /// Rank of a sensitivity class on the ladder (higher = more restrictive). An unknown/absent class
70    /// is treated **fail-closed** as the most restrictive rank — you cannot down-classify by mislabelling.
71    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/// The relational facts of a compiled geometry asset that plain per-property SHACL cannot see.
80#[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    /// The largest triangle vertex index used, if indices are being checked.
89    pub max_triangle_index: Option<u32>,
90    /// The `compiledDigest` asserted by the manifest.
91    pub claimed_compiled_digest: u32,
92    /// The actual whole-file CRC-32C of the `.10d` container the manifest describes
93    /// (recompute with `render::compile_10d::compiled_digest`).
94    pub actual_container_crc32c: u32,
95    /// Sensitivity classes of the inputs this asset was derived from (for the high-water-mark).
96    pub input_sensitivities: &'a [&'a str],
97    /// The sensitivity class declared on this asset (if any).
98    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/// A geometry-asset constraint violation.
106#[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    /// A bbox coordinate is NaN/∞ — no unit-bearing geometry may carry a non-finite box.
120    NonFiniteBbox,
121    /// `bboxMin[axis] > bboxMax[axis]` — an inverted (impossible) box.
122    InvertedBbox {
123        axis: u8,
124    },
125    /// A triangle references a vertex index `>= vertexCount`.
126    IndexOutOfBounds {
127        max_index: u32,
128        vertex_count: u32,
129    },
130    /// The manifest's `compiledDigest` does not equal the real `.10d` whole-file CRC — the manifest
131    /// does not describe the container it claims to.
132    CompiledDigestMismatch {
133        claimed: u32,
134        actual: u32,
135    },
136    /// A derived asset declared a **less** restrictive sensitivity than one of its inputs — the
137    /// high-water-mark forbids down-classifying derived geometry.
138    SensitivityDowngraded {
139        declared: String,
140        required: String,
141    },
142    /// validFrom > validUntil
143    TemporalValidityInverted {
144        from: u64,
145        until: u64,
146    },
147}
148
149/// Validate the relational constraints of a compiled geometry-asset manifest. Empty result = valid.
150/// This is the load-bearing check the declarative `.ttl` cannot perform.
151pub fn validate_geometry_manifest(
152    facts: &GeometryManifestFacts,
153    cfg: &GeometryAssetConfiguration,
154) -> Vec<GeometryConstraintViolation> {
155    use GeometryConstraintViolation::*;
156    let mut v = Vec::new();
157
158    // Counts in [1, max].
159    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    // Format / unit / licence membership (the sh:in sets).
173    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    // Bbox finite and non-inverted.
188    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    // Every triangle index < vertexCount.
204    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    // The manifest cites its real container.
214    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    // Sensitivity high-water-mark: declared ≥ the most-restrictive input.
222    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    // Temporal validity checks
238    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        // min(1) + 2×max + 4 formats + 5 units + 6 licences = 18 opcodes.
283        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]; // y min > y max
337        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); // == vertex_count (3) → OOB
352        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; // manifest cites a different container
365        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"]; // most restrictive input = Classified
378        f.declared_sensitivity = Some("Restricted"); // below Classified → downgrade
379        assert!(validate_geometry_manifest(&f, &cfg).contains(
380            &GeometryConstraintViolation::SensitivityDowngraded {
381                declared: "Restricted".to_string(),
382                required: "Classified".to_string(),
383            }
384        ));
385        // Declaring at-or-above the high-water-mark is fine.
386        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}