Skip to main content

ikigai_script/
graph.rs

1//! The graph face (`as=text/turtle`) of the catalog and the run records.
2//!
3//! PROV-O and the shared vocabulary, skolemized: every node is an IRI this crate already
4//! names, so two answers diff and merge without blank-node renaming.
5//!
6//! ```text
7//! <urn:script:catalog>  dcterms:hasPart  <urn:script:{name}>            one per catalog row
8//! <urn:script:{name}>   dcterms:identifier "{name}" ;
9//!                       ik:contentHash "sha256:…"                        its head version
10//! <urn:script:{name}:version:{digest}>  prov:specializationOf  <urn:script:{name}>
11//! <urn:script:{name}:run:{id}>  a prov:Activity ;
12//!     prov:used <urn:script:{name}:version:{digest}> ;                 the version that ran
13//!     prov:wasAssociatedWith <principal> ;                            who, as the door minted it
14//!     prov:startedAtTime "…"^^xsd:dateTime ; prov:endedAtTime "…"^^xsd:dateTime ;
15//!     ik:outcome <urn:script:outcome:ok> | <urn:script:outcome:failed>   absent while running
16//! ```
17//!
18//! A run reaches its script through the version it used, so "every run of this script" and
19//! "its last run" are queries, not terms.
20//!
21//! ⚠ **What the graph does not carry, because the vocabulary has no term for it** (the JSON
22//! face has every one): a script's state, its public flag, its language, what it declares,
23//! what its publisher held and their exclusions, and a broken head's error; a run's
24//! capability, the failure's kind and message, its result and whether it was cut, and its
25//! trace span. Nothing here invents an `ik:` term for them.
26
27use std::collections::HashSet;
28
29use oxrdf::{vocab::rdf, vocab::xsd, Literal, NamedNode, Triple};
30use oxttl::TurtleSerializer;
31
32use crate::model::{when, Outcome, Run};
33use crate::name;
34
35/// The shared vocabulary's namespace.
36const IK: &str = "https://ikigai-rs.dev/ns#";
37const PROV: &str = "http://www.w3.org/ns/prov#";
38const DCTERMS: &str = "http://purl.org/dc/terms/";
39const XSD: &str = "http://www.w3.org/2001/XMLSchema#";
40
41/// `ik:outcome` of a run that finished with an answer.
42pub const OUTCOME_OK: &str = "urn:script:outcome:ok";
43/// `ik:outcome` of a run that finished with an error.
44pub const OUTCOME_FAILED: &str = "urn:script:outcome:failed";
45
46/// The triples being built for one answer: a set, kept in the order first stated so the
47/// text reads top-down (a catalog row's head and its last run often name one version).
48#[derive(Default)]
49pub(crate) struct Graph {
50    triples: Vec<Triple>,
51    seen: HashSet<Triple>,
52}
53
54/// `text` as an IRI, when it is one.
55fn node(text: &str) -> Option<NamedNode> {
56    NamedNode::new(text).ok()
57}
58
59/// A term this module spells itself, so certainly an IRI.
60fn term(namespace: &str, local: &str) -> NamedNode {
61    NamedNode::new_unchecked(format!("{namespace}{local}"))
62}
63
64fn date_time(millis: u64) -> Literal {
65    Literal::new_typed_literal(when(Some(millis)), xsd::DATE_TIME)
66}
67
68impl Graph {
69    fn add(&mut self, subject: &NamedNode, predicate: NamedNode, object: impl Into<oxrdf::Term>) {
70        let triple = Triple::new(subject.clone(), predicate, object.into());
71        if self.seen.insert(triple.clone()) {
72            self.triples.push(triple);
73        }
74    }
75
76    /// `<version> prov:specializationOf <script>`: a version is the script, fixed.
77    fn version(&mut self, script: &str, digest: &str) -> Option<NamedNode> {
78        let version = node(&name::version_iri(script, digest))?;
79        let of = node(&name::script_iri(script))?;
80        self.add(&version, term(PROV, "specializationOf"), of);
81        Some(version)
82    }
83
84    /// One run, as far as `run` is known: the catalog's last run carries less than a record.
85    fn run(
86        &mut self,
87        iri: &str,
88        script: &str,
89        digest: &str,
90        outcome: &Outcome,
91        ended: Option<u64>,
92    ) -> Option<NamedNode> {
93        let subject = node(iri)?;
94        self.add(&subject, rdf::TYPE.into_owned(), term(PROV, "Activity"));
95        if let Some(version) = self.version(script, digest) {
96            self.add(&subject, term(PROV, "used"), version);
97        }
98        if let Some(ended) = ended {
99            self.add(&subject, term(PROV, "endedAtTime"), date_time(ended));
100        }
101        let outcome = match outcome {
102            Outcome::Running => None,
103            Outcome::Ok => Some(OUTCOME_OK),
104            Outcome::Failed { .. } => Some(OUTCOME_FAILED),
105        };
106        if let Some(outcome) = outcome.and_then(node) {
107            self.add(&subject, term(IK, "outcome"), outcome);
108        }
109        Some(subject)
110    }
111
112    /// A run record: what [`Graph::run`] says, and who ran it when.
113    pub(crate) fn record(&mut self, record: &Run) {
114        let Some(subject) = self.run(
115            &record.iri(),
116            &record.name,
117            &record.version,
118            &record.outcome,
119            record.ended,
120        ) else {
121            return;
122        };
123        // A principal that is not an IRI names no agent here; the JSON face still has it.
124        if let Some(agent) = node(&record.principal) {
125            self.add(&subject, term(PROV, "wasAssociatedWith"), agent);
126        }
127        if let Some(started) = record.started {
128            self.add(&subject, term(PROV, "startedAtTime"), date_time(started));
129        }
130    }
131
132    /// One catalog row: the script, its head version and, when it has run, its last run.
133    pub(crate) fn script(&mut self, script: &str, head: Option<&str>, last: Option<&Run>) {
134        let (Some(catalog), Some(subject)) =
135            (node(name::CATALOG_IRI), node(&name::script_iri(script)))
136        else {
137            return;
138        };
139        self.add(&catalog, term(DCTERMS, "hasPart"), subject.clone());
140        self.add(
141            &subject,
142            term(DCTERMS, "identifier"),
143            Literal::new_simple_literal(script),
144        );
145        if let Some(digest) = head {
146            self.add(
147                &subject,
148                term(IK, "contentHash"),
149                Literal::new_simple_literal(digest),
150            );
151            self.version(script, digest);
152        }
153        if let Some(run) = last {
154            self.run(&run.iri(), script, &run.version, &run.outcome, run.ended);
155        }
156    }
157
158    /// The Turtle text.
159    pub(crate) fn turtle(self) -> std::io::Result<Vec<u8>> {
160        let mut out = TurtleSerializer::new()
161            .with_prefix("ik", IK)
162            .and_then(|s| s.with_prefix("prov", PROV))
163            .and_then(|s| s.with_prefix("dcterms", DCTERMS))
164            .and_then(|s| s.with_prefix("xsd", XSD))
165            .map_err(std::io::Error::other)?
166            .for_writer(Vec::new());
167        for triple in &self.triples {
168            out.serialize_triple(triple)?;
169        }
170        out.finish()
171    }
172}