Skip to main content

oxirs_core/provenance/
provenance_bundle.rs

1//! PROV-O bundles and query-provenance tracking.
2//!
3//! This module defines [`ProvBundle`], a named collection of provenance
4//! statements that maps naturally to RDF named graphs, and
5//! [`QueryProvenanceTracker`], which captures the provenance of a SPARQL
6//! query execution and exports it as a bundle.
7
8use crate::model::{Literal, NamedNode, Object, Predicate, Subject, Triple};
9use crate::OxirsError;
10use std::collections::HashMap;
11
12use super::provenance_types::{
13    prov_iri, rdf_iri, AgentType, ProvActivity, ProvAgent, ProvEntity, ProvRelation,
14    ProvRelationKind, PROV_NS, RDF_NS,
15};
16
17// ── Provenance Bundle ─────────────────────────────────────────────────────────
18
19/// A PROV-O Bundle — a named collection of provenance statements
20///
21/// Bundles allow grouping provenance statements that describe the same
22/// provenance record. They map naturally to named graphs in RDF datasets.
23#[derive(Debug, Clone)]
24pub struct ProvBundle {
25    /// IRI identifying this bundle
26    pub iri: NamedNode,
27    /// Entities in this bundle
28    pub entities: Vec<ProvEntity>,
29    /// Activities in this bundle
30    pub activities: Vec<ProvActivity>,
31    /// Agents in this bundle
32    pub agents: Vec<ProvAgent>,
33    /// Relations between entities, activities, and agents
34    pub relations: Vec<ProvRelation>,
35}
36
37impl ProvBundle {
38    /// Create an empty provenance bundle
39    pub fn new(iri: NamedNode) -> Self {
40        Self {
41            iri,
42            entities: Vec::new(),
43            activities: Vec::new(),
44            agents: Vec::new(),
45            relations: Vec::new(),
46        }
47    }
48
49    /// Add an entity to this bundle
50    pub fn add_entity(&mut self, entity: ProvEntity) {
51        self.entities.push(entity);
52    }
53
54    /// Add an activity to this bundle
55    pub fn add_activity(&mut self, activity: ProvActivity) {
56        self.activities.push(activity);
57    }
58
59    /// Add an agent to this bundle
60    pub fn add_agent(&mut self, agent: ProvAgent) {
61        self.agents.push(agent);
62    }
63
64    /// Add a relation to this bundle
65    pub fn add_relation(&mut self, relation: ProvRelation) {
66        self.relations.push(relation);
67    }
68
69    /// Serialize the bundle to a flat Vec of RDF triples.
70    ///
71    /// The bundle IRI is typed as prov:Bundle. All entities, activities,
72    /// agents, and relations are serialized into the same flat triple list.
73    pub fn to_rdf(&self) -> Vec<Triple> {
74        let mut triples = Vec::new();
75
76        // Declare the bundle itself
77        triples.push(Triple::new(
78            self.iri.clone(),
79            rdf_iri("type"),
80            prov_iri("Bundle"),
81        ));
82
83        for entity in &self.entities {
84            triples.extend(entity.to_triples());
85        }
86        for activity in &self.activities {
87            triples.extend(activity.to_triples());
88        }
89        for agent in &self.agents {
90            triples.extend(agent.to_triples());
91        }
92        for relation in &self.relations {
93            triples.push(relation.to_triple());
94        }
95
96        triples
97    }
98
99    /// Parse a bundle from a slice of RDF triples.
100    ///
101    /// This is a best-effort round-trip: it reconstructs entities, activities,
102    /// agents, and relations from the triple patterns defined by PROV-O.
103    pub fn from_rdf(triples: &[Triple]) -> Result<Self, OxirsError> {
104        // Group triples by subject
105        let mut by_subject: HashMap<String, Vec<&Triple>> = HashMap::new();
106        for triple in triples {
107            let key = match triple.subject() {
108                Subject::NamedNode(n) => n.as_str().to_string(),
109                Subject::BlankNode(b) => b.as_str().to_string(),
110                _ => continue,
111            };
112            by_subject.entry(key).or_default().push(triple);
113        }
114
115        let type_pred_full = format!("{RDF_NS}type");
116        let bundle_type_full = format!("{PROV_NS}Bundle");
117
118        // Determine the bundle IRI — it is typed as prov:Bundle
119        let bundle_iri_str = triples
120            .iter()
121            .find(|t| {
122                matches!(t.predicate(), Predicate::NamedNode(p) if p.as_str() == type_pred_full)
123                    && matches!(t.object(), Object::NamedNode(o) if o.as_str() == bundle_type_full)
124            })
125            .and_then(|t| match t.subject() {
126                Subject::NamedNode(n) => Some(n.as_str().to_string()),
127                _ => None,
128            })
129            .ok_or_else(|| OxirsError::Parse("No prov:Bundle declaration found".to_string()))?;
130
131        let bundle_iri = NamedNode::new_unchecked(bundle_iri_str.clone());
132
133        // Classify subjects by rdf:type
134        let entity_type = format!("{PROV_NS}Entity");
135        let activity_type = format!("{PROV_NS}Activity");
136        let agent_type_iri_str = format!("{PROV_NS}Agent");
137        let software_type = format!("{PROV_NS}SoftwareAgent");
138        let person_type = format!("{PROV_NS}Person");
139        let org_type = format!("{PROV_NS}Organization");
140
141        let mut entities: Vec<ProvEntity> = Vec::new();
142        let mut activities: Vec<ProvActivity> = Vec::new();
143        let mut agents: Vec<ProvAgent> = Vec::new();
144        let mut relations: Vec<ProvRelation> = Vec::new();
145
146        // Build relation kind map (predicate IRI -> kind)
147        let relation_kind_map: HashMap<String, ProvRelationKind> = [
148            (
149                format!("{PROV_NS}wasGeneratedBy"),
150                ProvRelationKind::WasGeneratedBy,
151            ),
152            (
153                format!("{PROV_NS}wasDerivedFrom"),
154                ProvRelationKind::WasDerivedFrom,
155            ),
156            (
157                format!("{PROV_NS}wasAttributedTo"),
158                ProvRelationKind::WasAttributedTo,
159            ),
160            (format!("{PROV_NS}used"), ProvRelationKind::Used),
161            (
162                format!("{PROV_NS}wasAssociatedWith"),
163                ProvRelationKind::WasAssociatedWith,
164            ),
165            (
166                format!("{PROV_NS}wasInformedBy"),
167                ProvRelationKind::WasInformedBy,
168            ),
169            (
170                format!("{PROV_NS}actedOnBehalfOf"),
171                ProvRelationKind::ActedOnBehalfOf,
172            ),
173        ]
174        .into_iter()
175        .collect();
176
177        // Scan each triple for relations
178        for triple in triples {
179            let subj_iri = match triple.subject() {
180                Subject::NamedNode(n) => n.clone(),
181                _ => continue,
182            };
183            let pred_str = match triple.predicate() {
184                Predicate::NamedNode(p) => p.as_str().to_string(),
185                _ => continue,
186            };
187            let obj_iri = match triple.object() {
188                Object::NamedNode(o) => o.clone(),
189                _ => continue,
190            };
191
192            if let Some(kind) = relation_kind_map.get(&pred_str) {
193                relations.push(ProvRelation::new(kind.clone(), subj_iri, obj_iri));
194            }
195        }
196
197        // Classify each subject
198        for (subj_str, subj_triples) in &by_subject {
199            if subj_str == &bundle_iri_str {
200                continue;
201            }
202
203            // Collect types for this subject
204            let types: Vec<String> = subj_triples
205                .iter()
206                .filter(|t| {
207                    matches!(t.predicate(), Predicate::NamedNode(p) if p.as_str() == type_pred_full)
208                })
209                .filter_map(|t| match t.object() {
210                    Object::NamedNode(o) => Some(o.as_str().to_string()),
211                    _ => None,
212                })
213                .collect();
214
215            let iri = NamedNode::new_unchecked(subj_str.clone());
216
217            // Collect non-type, non-relation attributes
218            let attributes: Vec<(NamedNode, Object)> = subj_triples
219                .iter()
220                .filter_map(|t| {
221                    if let Predicate::NamedNode(p) = t.predicate() {
222                        let p_str = p.as_str();
223                        if p_str == type_pred_full {
224                            return None;
225                        }
226                        if relation_kind_map.contains_key(p_str) {
227                            return None;
228                        }
229                        Some((p.clone(), t.object().clone()))
230                    } else {
231                        None
232                    }
233                })
234                .collect();
235
236            if types.contains(&entity_type) {
237                entities.push(ProvEntity::with_attributes(iri, attributes));
238            } else if types.contains(&activity_type) {
239                let start_pred = format!("{PROV_NS}startedAtTime");
240                let end_pred = format!("{PROV_NS}endedAtTime");
241
242                let start = attributes
243                    .iter()
244                    .find(|(p, _)| p.as_str() == start_pred)
245                    .and_then(|(_, o)| match o {
246                        Object::Literal(l) => Some(l.value().to_string()),
247                        _ => None,
248                    });
249                let end = attributes
250                    .iter()
251                    .find(|(p, _)| p.as_str() == end_pred)
252                    .and_then(|(_, o)| match o {
253                        Object::Literal(l) => Some(l.value().to_string()),
254                        _ => None,
255                    });
256                let extra_attrs: Vec<(NamedNode, Object)> = attributes
257                    .into_iter()
258                    .filter(|(p, _)| p.as_str() != start_pred && p.as_str() != end_pred)
259                    .collect();
260                activities.push(ProvActivity::with_times(iri, start, end, extra_attrs));
261            } else if types.contains(&agent_type_iri_str) {
262                let agent_kind = if types.contains(&software_type) {
263                    AgentType::SoftwareAgent
264                } else if types.contains(&person_type) {
265                    AgentType::Person
266                } else if types.contains(&org_type) {
267                    AgentType::Organization
268                } else {
269                    AgentType::Person
270                };
271                agents.push(ProvAgent::with_attributes(iri, agent_kind, attributes));
272            }
273        }
274
275        Ok(Self {
276            iri: bundle_iri,
277            entities,
278            activities,
279            agents,
280            relations,
281        })
282    }
283}
284
285// ── Query provenance tracker ──────────────────────────────────────────────────
286
287/// Track the provenance of a SPARQL query execution
288///
289/// This captures who executed a query, when, against what dataset, and
290/// what result dataset was produced. It can be exported as a PROV-O bundle.
291#[derive(Debug, Clone)]
292pub struct QueryProvenanceTracker {
293    /// IRI identifying this specific query execution
294    pub query_iri: NamedNode,
295    /// When the query was executed (XSD dateTime string)
296    pub executed_at: String,
297    /// IRI of the software agent that ran the query
298    pub executed_by: NamedNode,
299    /// IRI of the input dataset (the graph queried over)
300    pub input_dataset: NamedNode,
301    /// IRI of the result dataset (the query output)
302    pub result_dataset: NamedNode,
303    /// Optional SPARQL query string
304    pub query_text: Option<String>,
305}
306
307impl QueryProvenanceTracker {
308    /// Create a new query provenance tracker
309    pub fn new(
310        query_iri: NamedNode,
311        executed_at: String,
312        executed_by: NamedNode,
313        input_dataset: NamedNode,
314        result_dataset: NamedNode,
315    ) -> Self {
316        Self {
317            query_iri,
318            executed_at,
319            executed_by,
320            input_dataset,
321            result_dataset,
322            query_text: None,
323        }
324    }
325
326    /// Attach the original SPARQL query text as a prov:value attribute
327    pub fn with_query_text(mut self, text: impl Into<String>) -> Self {
328        self.query_text = Some(text.into());
329        self
330    }
331
332    /// Convert this tracker to a PROV-O bundle
333    ///
334    /// The bundle represents:
335    /// - `result_dataset` was generated by `query_iri`
336    /// - `query_iri` used `input_dataset`
337    /// - `query_iri` was associated with `executed_by`
338    /// - `result_dataset` was attributed to `executed_by`
339    pub fn to_bundle(&self) -> ProvBundle {
340        let bundle_iri =
341            NamedNode::new_unchecked(format!("{}/provenance", self.query_iri.as_str()));
342        let mut bundle = ProvBundle::new(bundle_iri);
343
344        // Entities: input and result datasets
345        bundle.add_entity(ProvEntity::new(self.input_dataset.clone()));
346        bundle.add_entity(ProvEntity::new(self.result_dataset.clone()));
347
348        // Activity: the query execution itself
349        let mut activity_attrs: Vec<(NamedNode, Object)> = Vec::new();
350        if let Some(ref text) = self.query_text {
351            activity_attrs.push((
352                prov_iri("value"),
353                Object::Literal(Literal::new(text.as_str())),
354            ));
355        }
356
357        bundle.add_activity(ProvActivity::with_times(
358            self.query_iri.clone(),
359            Some(self.executed_at.clone()),
360            Some(self.executed_at.clone()),
361            activity_attrs,
362        ));
363
364        // Agent: the software agent
365        bundle.add_agent(ProvAgent::new(
366            self.executed_by.clone(),
367            AgentType::SoftwareAgent,
368        ));
369
370        // Relations
371        bundle.add_relation(ProvRelation::new(
372            ProvRelationKind::WasGeneratedBy,
373            self.result_dataset.clone(),
374            self.query_iri.clone(),
375        ));
376        bundle.add_relation(ProvRelation::new(
377            ProvRelationKind::Used,
378            self.query_iri.clone(),
379            self.input_dataset.clone(),
380        ));
381        bundle.add_relation(ProvRelation::new(
382            ProvRelationKind::WasAssociatedWith,
383            self.query_iri.clone(),
384            self.executed_by.clone(),
385        ));
386        bundle.add_relation(ProvRelation::new(
387            ProvRelationKind::WasAttributedTo,
388            self.result_dataset.clone(),
389            self.executed_by.clone(),
390        ));
391
392        bundle
393    }
394}