Skip to main content

contextgraph_types/
attribution.rs

1//! Retrieval attribution: closing the loop from a served frame to what it did
2//! (`SPEC.md` §14; issue #31).
3//!
4//! The Context Frame spec's sixth question is *"why was each item included, and
5//! can its effect be evaluated later?"* Provenance answers the first half —
6//! where an item came from, and (§6.2) whether its bytes are still what they
7//! claim. Nothing answered the second. A host could tell you a frame cost 42
8//! tokens and came from `retry-policy.md`, and nothing at all about whether
9//! including it helped.
10//!
11//! That gap is not theoretical: a host in this ecosystem already A/B-suppresses
12//! recall on a fraction of turns to measure whether retrieval earns its budget,
13//! entirely outside the protocol, because the protocol gave it no vocabulary to
14//! say so.
15//!
16//! # What this is, and what it deliberately is not
17//!
18//! This is a **host-produced record**, exactly like [`UsageReport`](crate::UsageReport)
19//! — not a wire method. There is no `context/feedback` envelope, no
20//! `Capabilities.feedback`, and no host API that transmits any of this to a
21//! provider.
22//!
23//! That restraint is the point. [ADR 0004](../../docs/adr/0004-dead-capability-surface.md)
24//! purged `upsert`, `subscribe`, and `filters` from the 1.0 surface for being
25//! capabilities no host could exercise, and §Q1 had to be written because
26//! `kinds` shipped as a request field that every implementation ignored. Adding
27//! a negotiated feedback method days before a freeze — with no provider
28//! consuming it and no conformance check able to witness it — would recreate
29//! precisely the defect that work removed.
30//!
31//! So the attribution *vocabulary* is specified now, because it is the half
32//! that has to be shared for scores to be comparable across implementations,
33//! and the wire hop that ships it back to a provider is deferred to a 1.x
34//! additive minor (`docs/sketches/attribution-feedback.md`). Hosts can score
35//! retrieval locally today; when a provider exists that consumes the signal,
36//! the shape it consumes is already agreed.
37//!
38//! # The identity is not new
39//!
40//! Attribution needs a stable per-item handle, and the protocol already has
41//! one: [`FrameId`](crate::FrameId), the `(provider id, frame id, content
42//! digest)` triple that composition, dedup, usage reports, and `verify` all
43//! key on. Minting a second id for attribution would let the two disagree —
44//! and a disagreement between "the frame that was billed" and "the frame that
45//! was cited" is exactly the confusion this record exists to prevent.
46
47use serde::{Deserialize, Serialize};
48
49use crate::identity::FrameId;
50use crate::usage::UsageReport;
51
52/// What actually became of one served frame, as three independent observations
53/// (the `context_use` vocabulary of [ADR 0007](../../docs/adr/0007-protocol-product-boundary.md)).
54///
55/// They are deliberately **not** a single enum or a score. Each is a distinct,
56/// separately-observable fact, and collapsing them would destroy the signal
57/// that matters most: a frame that was `selected` and `rendered` but never
58/// `cited` is the interesting case — the host paid its tokens and the model
59/// read it, and it changed nothing. A single "used/unused" flag cannot express
60/// that, and a 0–1 usefulness score would invent a precision nobody measured.
61///
62/// The three are ordered by inclusion in practice — a frame is rendered only if
63/// selected, cited only if rendered — but that is an observation about honest
64/// hosts, not an invariant this type enforces. See [`is_coherent`](Self::is_coherent).
65// No `Default`, for the same reason [`FrameId`] has none: a record about no
66// particular frame is not a sensible starting value, it is an un-reconcilable
67// one. Build from [`ContextUse::selected`].
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ContextUse {
70    /// The frame this record is about — the same identity the usage report
71    /// billed and `verify` revalidates, never a separate attribution id.
72    pub frame: FrameId,
73    /// The host chose this frame from the fan-out results: it survived consent,
74    /// the budget audit, and ranking.
75    #[serde(default)]
76    pub selected: bool,
77    /// The frame's content was actually composed into the prompt the model saw.
78    /// Distinct from `selected`: a frame can win ranking and still be dropped
79    /// by budget packing before it reaches the prompt.
80    #[serde(default)]
81    pub rendered: bool,
82    /// The model's output referred to this frame — by citation label, or by
83    /// whatever attribution the host can observe.
84    ///
85    /// A *claim about observable output*, never an inference about influence.
86    /// Whether a frame changed the model's reasoning is unobservable from
87    /// outside; recording "it was cited" is a fact, recording "it helped" would
88    /// be a guess wearing a fact's clothes.
89    #[serde(default)]
90    pub cited: bool,
91}
92
93impl ContextUse {
94    /// A record for a frame the host selected but has not yet observed further.
95    pub fn selected(frame: FrameId) -> Self {
96        Self {
97            frame,
98            selected: true,
99            rendered: false,
100            cited: false,
101        }
102    }
103
104    /// Mark the frame as having reached the prompt.
105    pub fn rendered(mut self) -> Self {
106        self.rendered = true;
107        self
108    }
109
110    /// Mark the frame as referred to by the model's output.
111    pub fn cited(mut self) -> Self {
112        self.cited = true;
113        self
114    }
115
116    /// Whether the three observations are mutually consistent: a frame cannot
117    /// be cited without having been rendered, nor rendered without having been
118    /// selected.
119    ///
120    /// A host that reports an incoherent record has an accounting bug, and
121    /// scoring on it would silently mis-attribute value — so this is checkable
122    /// rather than assumed.
123    pub fn is_coherent(&self) -> bool {
124        (!self.cited || self.rendered) && (!self.rendered || self.selected)
125    }
126
127    /// The frame reached the prompt and earned nothing observable — the case
128    /// worth paying attention to, because it is pure spent budget.
129    pub fn is_rendered_but_uncited(&self) -> bool {
130        self.rendered && !self.cited
131    }
132}
133
134/// Every [`ContextUse`] for one request, alongside the [`UsageReport`] that
135/// says what those frames cost.
136///
137/// Cost and outcome are kept in one place on purpose: separately, each is
138/// nearly useless. "This frame cost 400 tokens" prompts no decision, and "this
139/// frame was never cited" prompts the wrong one if it cost four. Together they
140/// give [`value_per_token`](Self::cited_token_share) — the ranking signal #31
141/// and the token-cost work (#8) each supply half of.
142// No `Default`: an attribution report without the usage report it reconciles
143// against is not a degenerate case, it is a meaningless one — every ratio this
144// type computes needs the cost side to exist.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct AttributionReport {
147    /// One record per frame the host selected, keyed by the same identity the
148    /// usage report bills.
149    #[serde(default)]
150    pub uses: Vec<ContextUse>,
151    /// The cost side of the ledger for the same request.
152    pub usage: UsageReport,
153}
154
155impl AttributionReport {
156    /// Pair outcome records with the cost report for the same request.
157    pub fn new(uses: Vec<ContextUse>, usage: UsageReport) -> Self {
158        Self { uses, usage }
159    }
160
161    /// The record for one frame identity, if the host observed it.
162    pub fn use_of(&self, frame: &FrameId) -> Option<&ContextUse> {
163        self.uses.iter().find(|entry| &entry.frame == frame)
164    }
165
166    /// Summed `token_cost` of frames that reached the prompt and were cited.
167    pub fn cited_tokens(&self) -> u64 {
168        self.tokens_where(|entry| entry.cited)
169    }
170
171    /// Summed `token_cost` of frames that reached the prompt and were **not**
172    /// cited — the budget retrieval spent without visible return.
173    pub fn uncited_rendered_tokens(&self) -> u64 {
174        self.tokens_where(ContextUse::is_rendered_but_uncited)
175    }
176
177    /// The share of rendered budget that earned a citation, in `[0, 1]`.
178    ///
179    /// `None` when nothing was rendered — a request that retrieved nothing has
180    /// no retrieval quality to report, and returning `0.0` would drag an
181    /// average down with a turn that never asked anything of retrieval.
182    pub fn cited_token_share(&self) -> Option<f64> {
183        let rendered = self.tokens_where(|entry| entry.rendered);
184        if rendered == 0 {
185            return None;
186        }
187        Some(self.cited_tokens() as f64 / rendered as f64)
188    }
189
190    /// Whether every record is internally coherent and names a frame the usage
191    /// report actually billed.
192    ///
193    /// The second half is what makes attribution auditable: a record about a
194    /// frame nobody was charged for cannot be reconciled against the bill, and
195    /// is the shape a mis-keyed identity takes.
196    pub fn is_reconcilable(&self) -> bool {
197        self.uses.iter().all(|entry| {
198            entry.is_coherent()
199                && self.usage.providers.iter().any(|provider| {
200                    provider
201                        .served_frames
202                        .iter()
203                        .any(|s| s.frame == entry.frame)
204                })
205        })
206    }
207
208    fn tokens_where(&self, predicate: impl Fn(&ContextUse) -> bool) -> u64 {
209        self.uses
210            .iter()
211            .filter(|entry| predicate(entry))
212            .filter_map(|entry| {
213                self.usage
214                    .providers
215                    .iter()
216                    .flat_map(|provider| provider.served_frames.iter())
217                    .find(|served| served.frame == entry.frame)
218                    .map(|served| served.token_cost as u64)
219            })
220            .sum()
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::usage::{ProviderUsage, ServedFrame};
228
229    fn frame(id: &str) -> FrameId {
230        FrameId::new("docs", id, Some(format!("sha256:{id}")))
231    }
232
233    fn report(costs: &[(&str, u32)]) -> UsageReport {
234        let served: Vec<ServedFrame> = costs
235            .iter()
236            .map(|(id, cost)| ServedFrame {
237                frame: frame(id),
238                token_cost: *cost,
239            })
240            .collect();
241        let total: u64 = served.iter().map(|s| s.token_cost as u64).sum();
242        UsageReport {
243            budget_requested: 4096,
244            budget_consumed: total,
245            as_of: "2026-07-25T00:00:00Z".into(),
246            providers: vec![ProviderUsage {
247                provider_id: "docs".into(),
248                frames_served: served.len() as u32,
249                frames_rejected: 0,
250                token_cost: total,
251                served_frames: served,
252            }],
253        }
254    }
255
256    #[test]
257    fn cost_and_outcome_together_give_a_value_signal() {
258        let usage = report(&[("a", 100), ("b", 300)]);
259        let attribution = AttributionReport::new(
260            vec![
261                ContextUse::selected(frame("a")).rendered().cited(),
262                ContextUse::selected(frame("b")).rendered(),
263            ],
264            usage,
265        );
266
267        assert_eq!(attribution.cited_tokens(), 100);
268        assert_eq!(attribution.uncited_rendered_tokens(), 300);
269        // A quarter of the rendered budget earned a citation — the number
270        // neither cost nor outcome could produce alone.
271        assert_eq!(attribution.cited_token_share(), Some(0.25));
272    }
273
274    #[test]
275    fn a_request_that_rendered_nothing_has_no_quality_to_report() {
276        // Not 0.0: a turn that never asked anything of retrieval must not drag
277        // down an average of turns that did.
278        let attribution = AttributionReport::new(
279            vec![ContextUse::selected(frame("a"))],
280            report(&[("a", 100)]),
281        );
282        assert_eq!(attribution.cited_token_share(), None);
283    }
284
285    #[test]
286    fn selected_but_unrendered_costs_nothing_against_quality() {
287        // Ranked in, then dropped by budget packing before the prompt. It was
288        // never shown, so it is neither credit nor debit.
289        let attribution = AttributionReport::new(
290            vec![
291                ContextUse::selected(frame("a")).rendered().cited(),
292                ContextUse::selected(frame("b")),
293            ],
294            report(&[("a", 100), ("b", 300)]),
295        );
296        assert_eq!(attribution.cited_token_share(), Some(1.0));
297        assert_eq!(attribution.uncited_rendered_tokens(), 0);
298    }
299
300    #[test]
301    fn incoherent_records_are_detectable() {
302        let cited_without_rendering = ContextUse {
303            frame: frame("a"),
304            selected: true,
305            rendered: false,
306            cited: true,
307        };
308        assert!(!cited_without_rendering.is_coherent());
309
310        let rendered_without_selecting = ContextUse {
311            frame: frame("a"),
312            selected: false,
313            rendered: true,
314            cited: false,
315        };
316        assert!(!rendered_without_selecting.is_coherent());
317
318        assert!(
319            ContextUse::selected(frame("a"))
320                .rendered()
321                .cited()
322                .is_coherent()
323        );
324    }
325
326    #[test]
327    fn a_record_naming_an_unbilled_frame_does_not_reconcile() {
328        // The shape a mis-keyed identity takes: attribution that cannot be
329        // walked back to the bill is not auditable.
330        let attribution = AttributionReport::new(
331            vec![ContextUse::selected(frame("ghost")).rendered()],
332            report(&[("a", 100)]),
333        );
334        assert!(!attribution.is_reconcilable());
335
336        let honest = AttributionReport::new(
337            vec![ContextUse::selected(frame("a")).rendered()],
338            report(&[("a", 100)]),
339        );
340        assert!(honest.is_reconcilable());
341    }
342}