Skip to main content

okf_core/
provenance.rs

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