cel_brief/receipt.rs
1//! [`BriefReceipt`] — auditable record of how a [`crate::types::Brief`] was
2//! assembled.
3//!
4//! Populated end-to-end by [`crate::builder::BriefBuilder::build`] in
5//! Phase 2; [`BriefReceipt::empty`] remains for tests and for callers that
6//! hand-construct a `Brief` outside the builder.
7
8use std::collections::HashMap;
9use std::time::{Duration, SystemTime};
10
11use serde::{Deserialize, Serialize};
12
13use crate::types::{Priority, SourceId};
14
15/// Per-source stats kept on the [`BriefReceipt`].
16///
17/// The builder fills one entry per [`crate::source::Source`] it called,
18/// regardless of whether that source contributed surviving items — a source
19/// that returned 0 contributions or had all its contributions pruned still
20/// gets a `SourceStats` entry with the corresponding counts so the receipt
21/// is exhaustive.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct SourceStats {
24 /// Number of [`crate::source::Contribution`]s the source returned.
25 pub contributions: usize,
26 /// Number of contributions that survived budget pruning and governance.
27 pub kept: usize,
28 /// Total tokens attributed to the source after pruning.
29 pub tokens: usize,
30 /// Source's [`Priority`] at the time of the build.
31 pub priority: Priority,
32}
33
34/// Reason a single [`crate::source::Contribution`] was dropped during
35/// pruning.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum DropReason {
39 /// Pruned to fit the [`crate::types::TokenBudget`].
40 OverBudget,
41 /// Removed by governance (Phase 4).
42 Governance,
43}
44
45/// Record of a contribution that didn't make it into the final
46/// [`crate::types::Brief`].
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct DroppedContribution {
49 /// Source that produced the dropped contribution.
50 pub source: SourceId,
51 /// Why it was dropped.
52 pub reason: DropReason,
53 /// Tokens it would have cost.
54 pub tokens: usize,
55}
56
57/// Record of a content rewrite applied by governance (Phase 4).
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct RedactionRecord {
60 /// Source whose contribution was redacted.
61 pub source: SourceId,
62 /// Short, opaque rule label (e.g. `"rule:no_bank_dom"`).
63 pub rule: String,
64}
65
66/// Per-phase timings collected by the builder. All durations are wall-clock
67/// time within `BriefBuilder::build`.
68///
69/// `fanout` is the **longest** source `contribute()` time, not the sum —
70/// sources are fanned out in parallel, so wall-clock is bounded by the
71/// slowest source plus serialisation overhead.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
73pub struct Timings {
74 /// Longest source `contribute()` time (max across the fan-out).
75 pub fanout: Duration,
76 /// Time spent tokenizing contributions.
77 pub tokenize: Duration,
78 /// Time spent in budget pruning.
79 pub prune: Duration,
80 /// Time spent in `governance.review`.
81 pub governance: Duration,
82 /// Total wall-clock time inside `build`.
83 pub total: Duration,
84}
85
86/// Auditable record of how a [`crate::types::Brief`] was assembled.
87///
88/// Populated by [`crate::builder::BriefBuilder::build`]. Callers can still
89/// construct an empty receipt via [`BriefReceipt::empty`] when assembling a
90/// brief by hand (tests, fixtures).
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct BriefReceipt {
93 /// Wall-clock time the receipt was finalised.
94 pub built_at: SystemTime,
95 /// Total tokens in the assembled brief.
96 pub total_tokens: usize,
97 /// Per-source breakdown — one entry per source the builder consulted,
98 /// even if the source produced zero contributions.
99 pub by_source: HashMap<SourceId, SourceStats>,
100 /// Contributions pruned for budget or governance reasons.
101 pub dropped: Vec<DroppedContribution>,
102 /// Governance rewrites applied to surviving contributions.
103 pub redactions: Vec<RedactionRecord>,
104 /// Per-phase timings.
105 pub timings: Timings,
106}
107
108impl BriefReceipt {
109 /// An empty receipt with `built_at = SystemTime::now()` and zero counts.
110 ///
111 /// Useful for tests and for callers that hand-assemble a [`crate::types::Brief`]
112 /// outside the builder. Phase 2 builder callers should let
113 /// [`crate::builder::BriefBuilder::build`] populate the receipt for
114 /// them.
115 pub fn empty() -> Self {
116 BriefReceipt {
117 built_at: SystemTime::now(),
118 total_tokens: 0,
119 by_source: HashMap::new(),
120 dropped: Vec::new(),
121 redactions: Vec::new(),
122 timings: Timings::default(),
123 }
124 }
125}
126
127impl Default for BriefReceipt {
128 fn default() -> Self {
129 BriefReceipt::empty()
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn empty_receipt_has_zero_counts() {
139 let receipt = BriefReceipt::empty();
140 assert_eq!(receipt.total_tokens, 0);
141 assert!(receipt.by_source.is_empty());
142 assert!(receipt.dropped.is_empty());
143 assert!(receipt.redactions.is_empty());
144 assert_eq!(receipt.timings, Timings::default());
145 }
146
147 #[test]
148 fn dropped_round_trips_through_serde_json() {
149 let dropped = DroppedContribution {
150 source: SourceId::new("history"),
151 reason: DropReason::OverBudget,
152 tokens: 42,
153 };
154 let json = serde_json::to_string(&dropped).expect("serialize");
155 let back: DroppedContribution = serde_json::from_str(&json).expect("deserialize");
156 assert_eq!(dropped, back);
157 }
158
159 #[test]
160 fn source_stats_round_trips_through_serde_json() {
161 let stats = SourceStats {
162 contributions: 3,
163 kept: 2,
164 tokens: 120,
165 priority: Priority::Normal,
166 };
167 let json = serde_json::to_string(&stats).expect("serialize");
168 let back: SourceStats = serde_json::from_str(&json).expect("deserialize");
169 assert_eq!(stats, back);
170 }
171
172 #[test]
173 fn redaction_record_round_trips_through_serde_json() {
174 let rec = RedactionRecord {
175 source: SourceId::new("perception"),
176 rule: "rule:no_bank_dom".into(),
177 };
178 let json = serde_json::to_string(&rec).expect("serialize");
179 let back: RedactionRecord = serde_json::from_str(&json).expect("deserialize");
180 assert_eq!(rec, back);
181 }
182}