Skip to main content

qualia_core_db/sparql_library/
vision_shacl.rs

1//! SHACL-style validation for visual observation graphs (design §6.3.8 / first-release).
2//!
3//! Caller-buffered, pure checks on NQuin slices. Does not claim certified model quality —
4//! only structural integrity of epistemic claims (source, model digest, region bounds, score).
5
6use crate::q_hash;
7use crate::NQuin;
8
9/// Predicate IRIs (must match `qualia-vision` semantic + client vision_ingest).
10pub const P_VISUAL_OBSERVATION: &str = "https://ns.webizen.org/q42/VisualObservation";
11pub const P_PROPOSES_CLASS: &str = "https://ns.webizen.org/q42/proposesClass";
12pub const P_HAS_BBOX: &str = "https://ns.webizen.org/q42/hasBoundingBox";
13pub const P_HAS_TRACK: &str = "https://ns.webizen.org/q42/hasTrackId";
14pub const P_MODEL_DIGEST: &str = "https://ns.webizen.org/q42/modelDigest";
15pub const P_HUMAN_REJECTS: &str = "https://ns.webizen.org/q42/humanRejects";
16pub const P_HUMAN_CORRECTS: &str = "https://ns.webizen.org/q42/humanCorrectsClass";
17
18#[repr(u8)]
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum VisionShaclViolation {
21    MissingModelDigest = 1,
22    ObservationWithoutClass = 2,
23    InvalidBbox = 3,
24    ScoreOutOfRange = 4,
25    EmptyGraph = 5,
26    OrphanClassProposal = 6,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct VisionShaclReport {
31    pub ok: bool,
32    pub observation_count: u32,
33    pub class_count: u32,
34    pub bbox_count: u32,
35    pub model_digest_count: u32,
36    pub human_attestation_count: u32,
37    pub violation: Option<VisionShaclViolation>,
38}
39
40impl VisionShaclReport {
41    pub const EMPTY_OK: Self = Self {
42        ok: false,
43        observation_count: 0,
44        class_count: 0,
45        bbox_count: 0,
46        model_digest_count: 0,
47        human_attestation_count: 0,
48        violation: Some(VisionShaclViolation::EmptyGraph),
49    };
50}
51
52#[inline]
53fn unpack_bbox(v: u64) -> (u16, u16, u16, u16) {
54    (
55        (v & 0xFFFF) as u16,
56        ((v >> 16) & 0xFFFF) as u16,
57        ((v >> 32) & 0xFFFF) as u16,
58        ((v >> 48) & 0xFFFF) as u16,
59    )
60}
61
62/// Validate a vision observation bundle (media hash as subject of observations optional).
63pub fn validate_vision_observation_graph(quins: &[NQuin]) -> VisionShaclReport {
64    if quins.is_empty() {
65        return VisionShaclReport::EMPTY_OK;
66    }
67
68    let p_obs = q_hash(P_VISUAL_OBSERVATION);
69    let p_class = q_hash(P_PROPOSES_CLASS);
70    let p_bbox = q_hash(P_HAS_BBOX);
71    let p_track = q_hash(P_HAS_TRACK);
72    let p_model = q_hash(P_MODEL_DIGEST);
73    let p_rej = q_hash(P_HUMAN_REJECTS);
74    let p_corr = q_hash(P_HUMAN_CORRECTS);
75
76    let mut report = VisionShaclReport {
77        ok: true,
78        observation_count: 0,
79        class_count: 0,
80        bbox_count: 0,
81        model_digest_count: 0,
82        human_attestation_count: 0,
83        violation: None,
84    };
85
86    // Collect instance hashes from observations (fixed stack buffer).
87    let mut instances = [0u64; 64];
88    let mut n_inst = 0usize;
89
90    for q in quins {
91        if q.predicate == p_model {
92            report.model_digest_count = report.model_digest_count.saturating_add(1);
93            if q.object == 0 {
94                report.ok = false;
95                report.violation = Some(VisionShaclViolation::MissingModelDigest);
96            }
97        } else if q.predicate == p_obs {
98            report.observation_count = report.observation_count.saturating_add(1);
99            if n_inst < instances.len() {
100                instances[n_inst] = q.object;
101                n_inst += 1;
102            }
103            // score in metadata low 16
104            let score = (q.metadata & 0xFFFF) as u32;
105            if score > 65535 {
106                report.ok = false;
107                report.violation = Some(VisionShaclViolation::ScoreOutOfRange);
108            }
109        } else if q.predicate == p_class {
110            report.class_count = report.class_count.saturating_add(1);
111            if q.object == 0 {
112                report.ok = false;
113                report.violation = Some(VisionShaclViolation::ObservationWithoutClass);
114            }
115        } else if q.predicate == p_bbox {
116            report.bbox_count = report.bbox_count.saturating_add(1);
117            let (x0, y0, x1, y1) = unpack_bbox(q.object);
118            if x1 < x0 || y1 < y0 {
119                report.ok = false;
120                report.violation = Some(VisionShaclViolation::InvalidBbox);
121            }
122        } else if q.predicate == p_track {
123            // track id may be 0 (untracked) — allowed
124        } else if q.predicate == p_rej || q.predicate == p_corr {
125            report.human_attestation_count = report.human_attestation_count.saturating_add(1);
126        }
127    }
128
129    if report.model_digest_count == 0 && report.observation_count > 0 {
130        report.ok = false;
131        report.violation = Some(VisionShaclViolation::MissingModelDigest);
132    }
133
134    // Each observation instance should have a class proposal (when we have room to check).
135    if report.ok && report.observation_count > 0 {
136        for i in 0..n_inst {
137            let inst = instances[i];
138            let has_class = quins
139                .iter()
140                .any(|q| q.predicate == p_class && q.subject == inst);
141            if !has_class {
142                report.ok = false;
143                report.violation = Some(VisionShaclViolation::ObservationWithoutClass);
144                break;
145            }
146        }
147    }
148
149    // Orphan class with no observation is a soft fail only if we have classes without any obs.
150    if report.ok && report.class_count > 0 && report.observation_count == 0 {
151        report.ok = false;
152        report.violation = Some(VisionShaclViolation::OrphanClassProposal);
153    }
154
155    report
156}
157
158/// Constraints description for tooling (hashes only).
159pub fn vision_shape_predicate_hashes(out: &mut [u64]) -> usize {
160    let preds = [
161        q_hash(P_MODEL_DIGEST),
162        q_hash(P_VISUAL_OBSERVATION),
163        q_hash(P_PROPOSES_CLASS),
164        q_hash(P_HAS_BBOX),
165        q_hash(P_HAS_TRACK),
166        q_hash(P_HUMAN_REJECTS),
167        q_hash(P_HUMAN_CORRECTS),
168    ];
169    let n = preds.len().min(out.len());
170    out[..n].copy_from_slice(&preds[..n]);
171    n
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn q(s: u64, p: u64, o: u64, c: u64, m: u64) -> NQuin {
179        NQuin {
180            subject: s,
181            predicate: p,
182            object: o,
183            context: c,
184            metadata: m,
185            parity: s ^ p ^ o ^ c ^ m,
186        }
187    }
188
189    #[test]
190    fn valid_bundle_passes() {
191        let media = 9u64;
192        let inst = 2u64;
193        let model = 7u64;
194        let bbox = (0u64) | (0u64 << 16) | (1000u64 << 32) | (2000u64 << 48);
195        let quins = [
196            q(media, q_hash(P_MODEL_DIGEST), model, 1, 64),
197            q(media, q_hash(P_VISUAL_OBSERVATION), inst, 1, 1000),
198            q(inst, q_hash(P_PROPOSES_CLASS), 99, 1, 1000),
199            q(inst, q_hash(P_HAS_BBOX), bbox, 1, 1000),
200        ];
201        let r = validate_vision_observation_graph(&quins);
202        assert!(r.ok, "{r:?}");
203        assert_eq!(r.observation_count, 1);
204        assert_eq!(r.model_digest_count, 1);
205    }
206
207    #[test]
208    fn missing_digest_fails() {
209        let quins = [q(9, q_hash(P_VISUAL_OBSERVATION), 2, 1, 1000)];
210        let r = validate_vision_observation_graph(&quins);
211        assert!(!r.ok);
212        assert_eq!(r.violation, Some(VisionShaclViolation::MissingModelDigest));
213    }
214
215    #[test]
216    fn invalid_bbox_fails() {
217        // x1 < x0
218        let bbox = (5000u64) | (0u64 << 16) | (100u64 << 32) | (2000u64 << 48);
219        let quins = [
220            q(9, q_hash(P_MODEL_DIGEST), 7, 1, 1),
221            q(9, q_hash(P_VISUAL_OBSERVATION), 2, 1, 1),
222            q(2, q_hash(P_PROPOSES_CLASS), 99, 1, 1),
223            q(2, q_hash(P_HAS_BBOX), bbox, 1, 1),
224        ];
225        let r = validate_vision_observation_graph(&quins);
226        assert!(!r.ok);
227        assert_eq!(r.violation, Some(VisionShaclViolation::InvalidBbox));
228    }
229
230    #[test]
231    fn human_reject_does_not_require_erasing_machine() {
232        let quins = [
233            q(9, q_hash(P_MODEL_DIGEST), 7, 1, 1),
234            q(9, q_hash(P_VISUAL_OBSERVATION), 2, 1, 1),
235            q(2, q_hash(P_PROPOSES_CLASS), 99, 1, 1),
236            q(0xD1D, q_hash(P_HUMAN_REJECTS), 2, 1, 0),
237        ];
238        let r = validate_vision_observation_graph(&quins);
239        assert!(r.ok);
240        assert_eq!(r.human_attestation_count, 1);
241        assert_eq!(r.class_count, 1); // machine claim retained
242    }
243}