Skip to main content

rto_graph/
provenance.rs

1//! Edge provenance classes.
2
3use serde::{Deserialize, Serialize};
4
5/// How an edge or node in the graph was produced.
6///
7/// # Six tokens, because "external" is a modifier and not a class
8///
9/// Three of these describe work **this** graph did: deterministic extraction,
10/// human authorship, heuristic inference. The other three describe the same
11/// three claims made by *someone else*, imported from a peer repository's Open
12/// Knowledge Format bundle ([[docs/adr/0021-open-knowledge-format-bundle.md]]).
13///
14/// The peer's tier is carried rather than collapsed, and that is the whole point
15/// of the shape. [`crate::Provenance`] is matched exhaustively by
16/// `rto_render::okf::origin_for` to produce OKF trust tiers, so a single flat
17/// `External` would force one arm — and therefore one answer for everything
18/// imported. Either it maps to *unverified*, which **downgrades** a peer's
19/// human-reviewed concept, or it maps to *machine-confirmed*, which **upgrades**
20/// their similarity guess. `render okf` then re-emits that flattened tier
21/// outward to the next consumer: laundering by round-trip, in a format adopted
22/// specifically because it can express the distinction.
23///
24/// # Externality does not nest
25///
26/// A fact we imported from B, which B had imported from C, is `external-*` —
27/// not doubly external. [`Provenance::externalise`] is idempotent for exactly
28/// that reason. Which repository the fact came *from* is not a property of the
29/// fact: it is the import layer's `src_ref`, which names B.
30///
31/// # What "external" does **not** modify
32///
33/// An `external-inferred` edge carries no confidence, where a local
34/// [`Provenance::Inferred`] one must (see [`crate::Edge::is_valid`]). A
35/// confidence is a number *we computed*; OKF carries none for a relationship, so
36/// adopting one would mean inventing it. That asymmetry is deliberate and is
37/// enforced by the store's own `CHECK` as well as by Rust.
38///
39/// # Compatibility
40///
41/// This enum had **three** variants up to and including `5.0.0`; the three
42/// `External*` variants arrived in **`5.1.0`**. It is `pub`, re-exported from
43/// the crate root, and deliberately not `#[non_exhaustive]`
44/// ([[docs/adr/0001-build-roteiro-unified-codebase-knowledge-graph.md]] v1.3
45/// argues why that is the right shape), so the addition is technically
46/// breaking for anyone who matches it exhaustively.
47///
48/// It shipped as a **minor**, deliberately and per policy: `AGENTS.md` treats
49/// the `rto-*` crates' public surface as internal, since they publish only so
50/// that `cargo install roteiro` resolves and `roteiro` is their sole reverse
51/// dependency. This note is the record that posture asks for, not a dissent
52/// from it.
53///
54/// If you do depend on this crate directly, two concrete consequences:
55///
56/// - An **exhaustive** `match` over the old three variants stops compiling.
57///   Add the three arms, or match with a wildcard if you only care about the
58///   local classes. A match that already had one is unaffected.
59/// - The serialized tokens are additive, so a `5.0.0` consumer reading a
60///   document written by `5.1.0` or later rejects `external-derived`,
61///   `external-authored` and `external-inferred` as unknown variants.
62///   `Provenance` rides every edge of every `roteiro.query/v1` document. On
63///   disk the guard for this is migration 14, which makes an older build
64///   report such a store as written by a newer Roteiro rather than as corrupt
65///   ([[docs/adr/0021-open-knowledge-format-bundle.md]]).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
67// Kebab-case, so serde's tokens and [`Provenance::as_str`]'s are the same six
68// strings. They have to be: a persisted import layer is `FactSet` JSON written
69// through serde, and the live rows it is applied to are written through
70// `as_str`. Two spellings of one value would make a layer and its own applied
71// rows disagree about what they hold.
72#[serde(rename_all = "kebab-case")]
73pub enum Provenance {
74    /// Deterministically extracted from source ASTs (tree-sitter). The default:
75    /// the overwhelmingly common node/edge, and the correct value when a legacy
76    /// cached fact set (serialized before nodes carried provenance) omits it.
77    #[default]
78    Derived,
79    /// Authored by a human or agent in an ADR, blueprint, or annotation.
80    Authored,
81    /// Heuristically inferred (docs, embeddings); carries a confidence score.
82    Inferred,
83    /// A peer's [`Provenance::Derived`] fact, imported from their bundle.
84    ///
85    /// They could re-derive it from their AST; **we** cannot, because we do not
86    /// have their tree. So this asserts *they* say it is deterministic, and
87    /// nothing about our ability to check.
88    ExternalDerived,
89    /// A peer's [`Provenance::Authored`] fact, imported from their bundle.
90    ///
91    /// Someone confirmed it **in their repository**. Importing it as
92    /// [`Provenance::Authored`] would assert that this graph human-authored it,
93    /// which is the laundering the whole variant exists to refuse.
94    ExternalAuthored,
95    /// A peer's [`Provenance::Inferred`] fact, imported from their bundle — or
96    /// any imported fact taken at *acknowledge* rather than *trust*: their
97    /// information without their confirmation.
98    ExternalInferred,
99}
100
101impl Provenance {
102    /// Parse a provenance from its stable string token, returning `None` for an
103    /// unrecognised value.
104    ///
105    /// Two things produce one: a corrupt database row, and a store written by a
106    /// **newer** Roteiro that knows a token this build does not. The second is
107    /// not hypothetical — this build added three — and it is diagnosed *before*
108    /// a row is decoded, by [`crate::Store::schema_ahead`]: every widening of
109    /// this set ships with a migration, so a store carrying an unknown token
110    /// necessarily records a migration this build has never heard of.
111    ///
112    /// Deliberately **not** tolerant of an unknown token. A fallback value would
113    /// have to be one of the six, and every choice is a claim: assuming
114    /// `derived` upgrades an unknown to machine-confirmed, assuming `inferred`
115    /// downgrades a confirmed fact. That is the same laundering the external
116    /// variants exist to prevent, arriving through the error path instead.
117    #[must_use]
118    pub fn from_token(s: &str) -> Option<Self> {
119        match s {
120            "derived" => Some(Self::Derived),
121            "authored" => Some(Self::Authored),
122            "inferred" => Some(Self::Inferred),
123            "external-derived" => Some(Self::ExternalDerived),
124            "external-authored" => Some(Self::ExternalAuthored),
125            "external-inferred" => Some(Self::ExternalInferred),
126            _ => None,
127        }
128    }
129
130    /// Stable string form used in the `SQLite` store.
131    #[must_use]
132    pub fn as_str(self) -> &'static str {
133        match self {
134            Self::Derived => "derived",
135            Self::Authored => "authored",
136            Self::Inferred => "inferred",
137            Self::ExternalDerived => "external-derived",
138            Self::ExternalAuthored => "external-authored",
139            Self::ExternalInferred => "external-inferred",
140        }
141    }
142
143    /// Every token, in the order the enum declares them — the exact set
144    /// [`Provenance::from_token`] accepts and the store's `CHECK` permits.
145    ///
146    /// Exists so an error message, a schema and a test can each name the
147    /// vocabulary without writing a fourth copy of it down.
148    #[must_use]
149    pub fn tokens() -> &'static [&'static str] {
150        &[
151            "derived",
152            "authored",
153            "inferred",
154            "external-derived",
155            "external-authored",
156            "external-inferred",
157        ]
158    }
159
160    /// Whether this fact came from another repository's bundle rather than from
161    /// this graph's own work.
162    #[must_use]
163    pub fn is_external(self) -> bool {
164        matches!(
165            self,
166            Self::ExternalDerived | Self::ExternalAuthored | Self::ExternalInferred
167        )
168    }
169
170    /// The tier this provenance claims, with externality stripped: what *kind*
171    /// of claim it is, ignoring whose claim it is.
172    ///
173    /// Use it where the question is genuinely about the tier — rendering a trust
174    /// level, ranking a fact's strength. Do **not** use it to decide whether a
175    /// fact may be rewritten, re-derived or asserted as this graph's own: those
176    /// questions are about ownership, and [`Provenance::is_external`] answers
177    /// them.
178    #[must_use]
179    pub fn tier(self) -> Self {
180        match self {
181            Self::Derived | Self::ExternalDerived => Self::Derived,
182            Self::Authored | Self::ExternalAuthored => Self::Authored,
183            Self::Inferred | Self::ExternalInferred => Self::Inferred,
184        }
185    }
186
187    /// This provenance as a peer's claim: the external variant carrying the same
188    /// tier.
189    ///
190    /// **Idempotent, and that is the decision rather than a convenience.**
191    /// Externality flattens to one level: a fact imported from B that B imported
192    /// from C is `external-*`, not doubly external, because the fact is external
193    /// exactly once and which repository it arrived from is the import layer's
194    /// `src_ref`, not the fact's class.
195    #[must_use]
196    pub fn externalise(self) -> Self {
197        match self {
198            Self::Derived | Self::ExternalDerived => Self::ExternalDerived,
199            Self::Authored | Self::ExternalAuthored => Self::ExternalAuthored,
200            Self::Inferred | Self::ExternalInferred => Self::ExternalInferred,
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::Provenance;
208
209    const ALL: [Provenance; 6] = [
210        Provenance::Derived,
211        Provenance::Authored,
212        Provenance::Inferred,
213        Provenance::ExternalDerived,
214        Provenance::ExternalAuthored,
215        Provenance::ExternalInferred,
216    ];
217
218    #[test]
219    fn stable_string_forms() {
220        assert_eq!(Provenance::Derived.as_str(), "derived");
221        assert_eq!(Provenance::Authored.as_str(), "authored");
222        assert_eq!(Provenance::Inferred.as_str(), "inferred");
223        assert_eq!(Provenance::ExternalDerived.as_str(), "external-derived");
224        assert_eq!(Provenance::ExternalAuthored.as_str(), "external-authored");
225        assert_eq!(Provenance::ExternalInferred.as_str(), "external-inferred");
226    }
227
228    #[test]
229    fn from_token_round_trips_and_rejects_unknown() {
230        for p in ALL {
231            assert_eq!(Provenance::from_token(p.as_str()), Some(p));
232        }
233        assert_eq!(Provenance::from_token("bogus"), None);
234        // Near-misses, because the tokens are a stored wire format and a reader
235        // that accepted a variant spelling would let two spellings of one value
236        // into the store.
237        assert_eq!(Provenance::from_token("external"), None);
238        assert_eq!(Provenance::from_token("external_authored"), None);
239        assert_eq!(Provenance::from_token("External-Authored"), None);
240    }
241
242    /// `tokens()` is the vocabulary, and it must be the *same* vocabulary
243    /// `from_token`/`as_str` implement — a third list that drifted would be a
244    /// schema `CHECK` permitting a value no code can read, or refusing one it
245    /// writes.
246    #[test]
247    fn the_token_list_is_the_accepted_set() {
248        let from_enum: Vec<&str> = ALL.iter().map(|p| p.as_str()).collect();
249        assert_eq!(Provenance::tokens(), from_enum.as_slice());
250        for token in Provenance::tokens() {
251            assert!(
252                Provenance::from_token(token).is_some(),
253                "`{token}` is listed but not parseable"
254            );
255        }
256    }
257
258    /// The serde wire form and the store token are one set of strings, not two.
259    /// A persisted import layer is `FactSet` JSON; the rows it is applied to are
260    /// written through `as_str`. If those disagreed, a layer would round-trip
261    /// into a different provenance than the one it applied.
262    #[test]
263    fn serde_and_the_store_token_agree() {
264        for p in ALL {
265            let json = serde_json::to_string(&p).expect("serialize");
266            assert_eq!(json, format!("\"{}\"", p.as_str()));
267            let back: Provenance = serde_json::from_str(&json).expect("deserialize");
268            assert_eq!(back, p);
269        }
270    }
271
272    #[test]
273    fn externalise_carries_the_tier_and_flattens() {
274        assert_eq!(
275            Provenance::Derived.externalise(),
276            Provenance::ExternalDerived
277        );
278        assert_eq!(
279            Provenance::Authored.externalise(),
280            Provenance::ExternalAuthored
281        );
282        assert_eq!(
283            Provenance::Inferred.externalise(),
284            Provenance::ExternalInferred
285        );
286        // Flattening to one level: importing an already-external fact from a
287        // peer who imported it themselves does not deepen anything.
288        for p in ALL {
289            assert_eq!(
290                p.externalise().externalise(),
291                p.externalise(),
292                "externalise must be idempotent for {p:?}"
293            );
294            assert!(p.externalise().is_external());
295        }
296    }
297
298    #[test]
299    fn tier_strips_externality_and_is_the_inverse_of_externalise() {
300        for p in ALL {
301            assert!(!p.tier().is_external());
302            assert_eq!(p.tier().externalise(), p.externalise());
303            assert_eq!(p.externalise().tier(), p.tier());
304        }
305        assert_eq!(Provenance::ExternalAuthored.tier(), Provenance::Authored);
306        assert_eq!(Provenance::Authored.tier(), Provenance::Authored);
307    }
308
309    #[test]
310    fn only_the_external_three_are_external() {
311        assert!(!Provenance::Derived.is_external());
312        assert!(!Provenance::Authored.is_external());
313        assert!(!Provenance::Inferred.is_external());
314        assert!(Provenance::ExternalDerived.is_external());
315        assert!(Provenance::ExternalAuthored.is_external());
316        assert!(Provenance::ExternalInferred.is_external());
317    }
318}