Skip to main content

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}