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}