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}