Skip to main content

okf_core/
provenance.rs

1//! Provenance: the `sources` frontmatter family and per-claim attribution.
2//!
3//! `sources` records the materials a concept derives from, external or internal
4//! to the bundle, together with the *credibility signals* (`author`,
5//! `usage_count`, `last_modified`) a consumer needs to judge how far to trust
6//! what was extracted from them.
7//!
8//! ```yaml
9//! sources:
10//!   - id: ga4-schema
11//!     resource: https://developers.google.com/analytics/bigquery/export-schema
12//!     title: GA4 BigQuery Export schema
13//!     author: team:ga4-docs
14//!     usage_count: 5000
15//!     last_modified: 2026-05-30T00:00:00Z
16//! usage_window: { from: 2026-06-01T00:00:00Z, to: 2026-06-30T00:00:00Z }
17//! ```
18//!
19//! Two design points from the spec show up directly in this module's API:
20//!
21//! - **No credibility score.** OKF stores objective signals, not a verdict: a
22//!   score is subjective, unportable, and goes stale. So there is no
23//!   `Source::score()`; credibility is inferred by the consumer from the
24//!   signals, the way [`TrustTier`](crate::trust::TrustTier) is inferred from
25//!   `verified`.
26//! - **Keyed, not positional, attribution.** A claim cites a source by footnote
27//!   label matching `sources[].id`, because agents constantly rewrite these
28//!   documents and a positional index (`sources[0]`) misattributes silently the
29//!   moment the list is reordered. [`attributions`] performs that join.
30
31use crate::actor::Actor;
32use crate::date::DateTimeField;
33use crate::footnotes;
34use crate::yaml::Value;
35use std::fmt;
36
37/// The date/time range that frames `usage_count`.
38///
39/// Written once as a sibling of `sources`; a single entry MAY carry its own to
40/// override the shared one.
41#[derive(Clone, Debug, Default, PartialEq, Eq)]
42pub struct UsageWindow {
43    /// Start of the window.
44    pub from: Option<DateTimeField>,
45    /// End of the window.
46    pub to: Option<DateTimeField>,
47}
48
49impl UsageWindow {
50    /// Reads a `{ from, to }` mapping. Returns `None` when the value is not a
51    /// mapping.
52    pub fn from_value(value: &Value) -> Option<Self> {
53        let map = value.as_mapping()?;
54        Some(Self {
55            from: map
56                .get("from")
57                .and_then(Value::as_display_string)
58                .map(DateTimeField::new),
59            to: map
60                .get("to")
61                .and_then(Value::as_display_string)
62                .map(DateTimeField::new),
63        })
64    }
65}
66
67impl fmt::Display for UsageWindow {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        let dash = |d: &Option<DateTimeField>| {
70            d.as_ref()
71                .map_or_else(|| "?".to_string(), |d| d.raw.clone())
72        };
73        write!(f, "{} to {}", dash(&self.from), dash(&self.to))
74    }
75}
76
77/// What kind of thing a `sources[].resource` names.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum ResourceKind {
80    /// An absolute URL.
81    Url,
82    /// A path a consumer can follow into the bundle (or a `references/` file).
83    Path,
84    /// A population or scope descriptor a consumer cannot follow, such as
85    /// `all queries in BigQuery project X`.
86    Scope,
87    /// No `resource` was given, so the entry is malformed, since `resource` is
88    /// REQUIRED within an entry.
89    Missing,
90}
91
92/// One entry in the `sources` list: a material the concept derives from.
93#[derive(Clone, Debug, Default, PartialEq, Eq)]
94pub struct Source {
95    /// A stable key used to attribute individual claims. SHOULD be present when
96    /// the body cites the source.
97    pub id: Option<String>,
98    /// REQUIRED within an entry: a concrete artifact or a scope descriptor.
99    pub resource: Option<String>,
100    /// Human-readable label for the source.
101    pub title: Option<String>,
102    /// Who or what produced the source, in the actor convention. An
103    /// authority signal.
104    pub author: Option<Actor>,
105    /// How often `resource` was exercised over the usage window. An adoption
106    /// and liveness signal: coarse, and not a cross-kind ranking.
107    pub usage_count: Option<i64>,
108    /// When the source itself last changed. A recency signal, distinct from
109    /// `generated.at` (which records when the *concept* was written).
110    pub last_modified: Option<DateTimeField>,
111    /// An entry-level override of the shared `usage_window`.
112    pub usage_window: Option<UsageWindow>,
113}
114
115impl Source {
116    /// Converts this source entry back into a YAML [`Value::Mapping`].
117    #[must_use]
118    pub fn to_yaml_value(&self) -> Value {
119        let mut map = crate::yaml::Mapping::new();
120        if let Some(id) = &self.id {
121            map.insert("id", Value::String(id.clone()));
122        }
123        if let Some(res) = &self.resource {
124            map.insert("resource", Value::String(res.clone()));
125        }
126        if let Some(title) = &self.title {
127            map.insert("title", Value::String(title.clone()));
128        }
129        if let Some(author) = &self.author {
130            map.insert("author", Value::String(author.as_str().to_string()));
131        }
132        if let Some(count) = self.usage_count {
133            map.insert("usage_count", Value::Int(count));
134        }
135        if let Some(last_mod) = &self.last_modified {
136            map.insert("last_modified", Value::String(last_mod.raw.clone()));
137        }
138        if let Some(window) = &self.usage_window {
139            let mut w_map = crate::yaml::Mapping::new();
140            if let Some(from) = &window.from {
141                w_map.insert("from", Value::String(from.raw.clone()));
142            }
143            if let Some(to) = &window.to {
144                w_map.insert("to", Value::String(to.raw.clone()));
145            }
146            map.insert("usage_window", Value::Mapping(w_map));
147        }
148        Value::Mapping(map)
149    }
150
151    /// Reads one `sources` entry. Returns `None` when the value is not a
152    /// mapping.
153    pub fn from_value(value: &Value) -> Option<Self> {
154        let map = value.as_mapping()?;
155        let string = |k: &str| map.get(k).and_then(Value::as_display_string);
156        Some(Self {
157            id: string("id"),
158            resource: string("resource"),
159            title: string("title"),
160            author: string("author").map(Actor::parse),
161            usage_count: map.get("usage_count").and_then(Value::as_int),
162            last_modified: string("last_modified").map(DateTimeField::new),
163            usage_window: map.get("usage_window").and_then(UsageWindow::from_value),
164        })
165    }
166
167    /// Reads a whole `sources` value into a list of entries.
168    ///
169    /// A bare mapping is accepted as a one-element list, mirroring the rule
170    /// the spec states for `verified`; any other shape yields an empty list.
171    pub fn list_from_value(value: &Value) -> Vec<Self> {
172        match value {
173            Value::Sequence(items) => items.iter().filter_map(Self::from_value).collect(),
174            Value::Mapping(_) => Self::from_value(value).into_iter().collect(),
175            _ => Vec::new(),
176        }
177    }
178
179    /// Classifies [`Source::resource`].
180    ///
181    /// Distinguishing a path from a scope descriptor is a heuristic (the spec
182    /// gives no syntax for either), so a resource containing whitespace is read
183    /// as a scope descriptor (`all queries in BigQuery project X`) and anything
184    /// else as a path. Consumers that only follow [`ResourceKind::Path`]
185    /// resources therefore never chase prose.
186    pub fn resource_kind(&self) -> ResourceKind {
187        match self.resource.as_deref().map(str::trim) {
188            None | Some("") => ResourceKind::Missing,
189            Some(r) if r.contains("://") || r.starts_with("mailto:") => ResourceKind::Url,
190            Some(r) if r.chars().any(char::is_whitespace) => ResourceKind::Scope,
191            Some(_) => ResourceKind::Path,
192        }
193    }
194
195    /// The usage window that frames this entry's `usage_count`: its own if it
196    /// has one, otherwise the shared sibling of `sources`.
197    #[must_use]
198    pub fn effective_usage_window<'a>(
199        &'a self,
200        shared: Option<&'a UsageWindow>,
201    ) -> Option<&'a UsageWindow> {
202        self.usage_window.as_ref().or(shared)
203    }
204
205    /// A short display label: the title, else the resource, else the id.
206    #[must_use]
207    pub fn label(&self) -> &str {
208        self.title
209            .as_deref()
210            .or(self.resource.as_deref())
211            .or(self.id.as_deref())
212            .unwrap_or("(unnamed source)")
213    }
214}
215
216impl fmt::Display for Source {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        match &self.id {
219            Some(id) => write!(f, "[{id}] {}", self.label()),
220            None => f.write_str(self.label()),
221        }
222    }
223}
224
225/// A body claim attributed to a source, produced by joining footnote labels to
226/// `sources[].id`.
227#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct Attribution {
229    /// The footnote label, which is the join key.
230    pub label: String,
231    /// The matching `sources` entry, or `None` when the label names no source.
232    pub source: Option<Source>,
233    /// How many times the body cites this label.
234    pub references: usize,
235    /// How many `[^label]: …` definition lines the body carries for it.
236    pub definitions: usize,
237}
238
239impl Attribution {
240    /// `true` when the label resolves to a `sources` entry.
241    #[must_use]
242    pub const fn is_resolved(&self) -> bool {
243        self.source.is_some()
244    }
245}
246
247/// Joins the body's footnotes to `sources` by label.
248///
249/// Every label that appears as a reference or a definition gets one entry, in
250/// order of first appearance. A label with no matching `sources[].id` still
251/// appears, with [`Attribution::source`] set to `None`: an unresolvable
252/// attribution is a producer mistake to report, not grounds for rejecting the
253/// document.
254#[must_use]
255pub fn attributions(sources: &[Source], body: &str) -> Vec<Attribution> {
256    let refs = footnotes::extract_refs(body);
257    let defs = footnotes::extract_definitions(body);
258
259    let mut order: Vec<String> = Vec::new();
260    let push = |label: &str, order: &mut Vec<String>| {
261        if !order.iter().any(|l| l == label) {
262            order.push(label.to_string());
263        }
264    };
265    for r in &refs {
266        push(&r.label, &mut order);
267    }
268    for d in &defs {
269        push(&d.label, &mut order);
270    }
271
272    order
273        .into_iter()
274        .map(|label| Attribution {
275            references: refs.iter().filter(|r| r.label == label).count(),
276            definitions: defs.iter().filter(|d| d.label == label).count(),
277            source: sources
278                .iter()
279                .find(|s| s.id.as_deref() == Some(label.as_str()))
280                .cloned(),
281            label,
282        })
283        .collect()
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::date::Date;
290
291    const SOURCES: &str = "\
292- id: rev-policy
293  resource: https://wiki.acme/finance/revenue-recognition
294  title: Revenue recognition policy
295  author: team:finance-fpa
296  last_modified: 2026-04-02T00:00:00Z
297- id: exec-rev-dash
298  resource: dashboards/exec-revenue
299  title: Executive revenue dashboard
300  author: team:finance-fpa
301  usage_count: 5000
302  last_modified: 2026-06-18T00:00:00Z
303";
304
305    fn sources() -> Vec<Source> {
306        Source::list_from_value(&Value::parse(SOURCES).unwrap())
307    }
308
309    #[test]
310    fn reads_entries_and_credibility_signals() {
311        let s = sources();
312        assert_eq!(s.len(), 2);
313        assert_eq!(s[0].id.as_deref(), Some("rev-policy"));
314        assert_eq!(s[0].resource_kind(), ResourceKind::Url);
315        assert_eq!(s[0].author.as_ref().unwrap().as_str(), "team:finance-fpa");
316        assert_eq!(
317            s[0].last_modified.as_ref().unwrap().datetime.unwrap().date,
318            Date::new(2026, 4, 2).unwrap()
319        );
320        assert_eq!(s[0].usage_count, None);
321
322        assert_eq!(s[1].usage_count, Some(5000));
323        assert_eq!(s[1].resource_kind(), ResourceKind::Path);
324        assert_eq!(s[1].label(), "Executive revenue dashboard");
325    }
326
327    #[test]
328    fn scope_descriptors_are_not_paths() {
329        let s = Source::from_value(
330            &Value::parse("{ resource: all queries in BigQuery project X }").unwrap(),
331        )
332        .unwrap();
333        assert_eq!(s.resource_kind(), ResourceKind::Scope);
334
335        let missing = Source::from_value(&Value::parse("{ id: x }").unwrap()).unwrap();
336        assert_eq!(missing.resource_kind(), ResourceKind::Missing);
337    }
338
339    #[test]
340    fn usage_window_entry_overrides_shared() {
341        let shared = UsageWindow::from_value(
342            &Value::parse("{ from: 2026-06-01T00:00:00Z, to: 2026-06-30T00:00:00Z }").unwrap(),
343        )
344        .unwrap();
345        let plain = &sources()[1];
346        assert_eq!(plain.effective_usage_window(Some(&shared)), Some(&shared));
347
348        let overridden = Source::from_value(
349            &Value::parse(
350                "{ resource: x, usage_window: { from: 2026-01-01T00:00:00Z, to: 2026-01-31T00:00:00Z } }",
351            )
352            .unwrap(),
353        )
354        .unwrap();
355        let window = overridden.effective_usage_window(Some(&shared)).unwrap();
356        assert_eq!(window.from.as_ref().unwrap().raw, "2026-01-01T00:00:00Z");
357    }
358
359    #[test]
360    fn attribution_joins_footnote_labels_to_source_ids() {
361        let body = "Per the recognition policy,[^rev-policy] corroborated by the \
362                    dashboard.[^exec-rev-dash] And once more.[^rev-policy]\n\n\
363                    [^rev-policy]: Revenue recognition policy\n\
364                    [^exec-rev-dash]: Executive revenue dashboard\n\
365                    [^ghost]: Not in sources\n";
366        let attributions = attributions(&sources(), body);
367        assert_eq!(attributions.len(), 3);
368
369        assert_eq!(attributions[0].label, "rev-policy");
370        assert_eq!(attributions[0].references, 2);
371        assert_eq!(attributions[0].definitions, 1);
372        assert!(attributions[0].is_resolved());
373        assert_eq!(
374            attributions[0].source.as_ref().unwrap().title.as_deref(),
375            Some("Revenue recognition policy")
376        );
377
378        // A label with no matching source is reported, not dropped.
379        assert_eq!(attributions[2].label, "ghost");
380        assert_eq!(attributions[2].references, 0);
381        assert!(!attributions[2].is_resolved());
382    }
383
384    #[test]
385    fn reordering_sources_does_not_change_attribution() {
386        let body = "Claim.[^exec-rev-dash]\n\n[^exec-rev-dash]: Executive revenue dashboard\n";
387        let mut reversed = sources();
388        reversed.reverse();
389        let a = attributions(&sources(), body);
390        let b = attributions(&reversed, body);
391        assert_eq!(a, b);
392        assert_eq!(
393            a[0].source.as_ref().unwrap().id.as_deref(),
394            Some("exec-rev-dash")
395        );
396    }
397}