boundgraph_core/condensate.rs
1//! The Condensate (`κ`) — a size-capped summary that never loses the original.
2//!
3//! This is the mechanism that turns quadratic context growth into linear
4//! growth. A raw tool result might be ten thousand lines; what the next step
5//! receives is a few hundred tokens of structured summary.
6//!
7//! Two properties make that safe rather than merely cheap:
8//!
9//! **It is typed, not a string.** A condensate carries a [`SchemaId`], so a
10//! downstream step can reason about the shape of a result without reading all
11//! of it, and caching is sound because equal payloads are genuinely equal.
12//!
13//! **The original is never destroyed.** [`Condensate::raw`] addresses the
14//! complete, unmodified output in the content-addressed store. The model sees
15//! the slice; an auditor can always fetch what it was sliced from. Without
16//! that, "the agent decided based on this output" would be unverifiable.
17
18use serde::{Deserialize, Serialize};
19
20use crate::digest::Digest;
21
22/// Names the shape of a condensate's payload.
23///
24/// Versioned, because a slicer that changes its output shape produces a
25/// different type of thing, and a consumer must be able to tell.
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
27pub struct SchemaId {
28 /// Schema name, e.g. `boundgraph.diff.v1`, `boundgraph.testrun.v1`.
29 pub name: String,
30 /// Schema version.
31 pub version: u32,
32}
33
34impl SchemaId {
35 /// Name a schema.
36 pub fn new(name: impl Into<String>, version: u32) -> Self {
37 Self {
38 name: name.into(),
39 version,
40 }
41 }
42}
43
44/// Which slicer produced a condensate, and at what version.
45///
46/// Recorded for reproducibility: replaying a run must be able to confirm that
47/// re-slicing the same raw output yields the same condensate, and that is only
48/// meaningful against a specific slicer version.
49#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
50pub struct SlicerId {
51 /// Slicer name, e.g. `ast`, `log`, `diff`, `json`.
52 pub name: String,
53 /// Slicer version.
54 pub version: u32,
55}
56
57impl SlicerId {
58 /// Name a slicer.
59 pub fn new(name: impl Into<String>, version: u32) -> Self {
60 Self {
61 name: name.into(),
62 version,
63 }
64 }
65}
66
67/// Where the summary itself lives.
68///
69/// Small payloads are inlined so reading a condensate needs no store lookup;
70/// large ones are referenced, so the ledger does not balloon. The threshold is
71/// a policy decision made by the slicer, not a property of the type.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum CondensatePayload {
75 /// The summary, carried directly.
76 Inline(ciborium::Value),
77 /// The summary, addressed in the content-addressed store.
78 Stored(Digest),
79}
80
81impl CondensatePayload {
82 /// Build an inline payload from any serialisable value.
83 ///
84 /// # Errors
85 ///
86 /// Returns [`CanonicalError::Encode`](crate::CanonicalError::Encode) if the
87 /// value cannot be represented as CBOR.
88 pub fn inline<T: Serialize>(value: &T) -> Result<Self, crate::CanonicalError> {
89 Ok(Self::Inline(ciborium::Value::serialized(value).map_err(
90 |e| crate::CanonicalError::Encode(e.to_string()),
91 )?))
92 }
93
94 /// Whether the payload is carried here rather than referenced.
95 pub fn is_inline(&self) -> bool {
96 matches!(self, Self::Inline(_))
97 }
98}
99
100/// `κ` — an AST-preserved, size-capped, high-density summary of a result.
101///
102/// # Example
103///
104/// ```
105/// use boundgraph_core::{Condensate, CondensatePayload, Digest, SchemaId, SlicerId};
106///
107/// let raw = b"...ten thousand lines of build output...";
108///
109/// let k = Condensate::new(
110/// SchemaId::new("boundgraph.log.v1", 1),
111/// CondensatePayload::inline(&"3 errors, first at src/main.rs:42")?,
112/// Digest::of(raw), // the original is still addressable
113/// 400, // the cap that was applied
114/// true, // slicing dropped information
115/// SlicerId::new("log", 1),
116/// );
117///
118/// assert!(k.lossy);
119/// assert_eq!(k.raw, Digest::of(raw));
120/// # Ok::<(), boundgraph_core::CanonicalError>(())
121/// ```
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct Condensate {
124 /// The shape of [`payload`](Self::payload). A condensate is typed, never a
125 /// bare blob of text.
126 pub schema: SchemaId,
127 /// The summary itself, inline or stored.
128 pub payload: CondensatePayload,
129 /// The complete, unmodified output this was derived from.
130 ///
131 /// Always present, even when nothing was dropped, so an auditor never has
132 /// to ask whether the original still exists.
133 pub raw: Digest,
134 /// The token cap actually applied when slicing.
135 pub cap: usize,
136 /// Whether slicing dropped information.
137 ///
138 /// Set honestly by the slicer. A consumer that needs completeness must
139 /// check this rather than assume.
140 pub lossy: bool,
141 /// Which slicer produced this, and at what version.
142 pub slicer: SlicerId,
143}
144
145impl Condensate {
146 /// Construct a condensate.
147 pub fn new(
148 schema: SchemaId,
149 payload: CondensatePayload,
150 raw: Digest,
151 cap: usize,
152 lossy: bool,
153 slicer: SlicerId,
154 ) -> Self {
155 Self {
156 schema,
157 payload,
158 raw,
159 cap,
160 lossy,
161 slicer,
162 }
163 }
164
165 /// Whether the full original must be fetched to see everything.
166 ///
167 /// Equivalent to [`lossy`](Self::lossy), named for the question a caller
168 /// actually has.
169 pub fn needs_raw_for_completeness(&self) -> bool {
170 self.lossy
171 }
172
173 /// Where to find the complete output.
174 pub const fn raw_digest(&self) -> &Digest {
175 &self.raw
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::canonical::Canonical;
183
184 fn condensate(lossy: bool) -> Condensate {
185 Condensate::new(
186 SchemaId::new("boundgraph.log.v1", 1),
187 CondensatePayload::inline(&"3 errors").unwrap(),
188 Digest::of(b"raw output"),
189 400,
190 lossy,
191 SlicerId::new("log", 1),
192 )
193 }
194
195 #[test]
196 fn carries_schema_payload_raw_cap_lossy_and_slicer() {
197 let k = condensate(true);
198 assert_eq!(k.schema, SchemaId::new("boundgraph.log.v1", 1));
199 assert!(k.payload.is_inline());
200 assert_eq!(k.raw, Digest::of(b"raw output"));
201 assert_eq!(k.cap, 400);
202 assert!(k.lossy);
203 assert_eq!(k.slicer, SlicerId::new("log", 1));
204 }
205
206 #[test]
207 fn the_original_is_always_addressable() {
208 // True whether or not anything was dropped.
209 assert_eq!(*condensate(true).raw_digest(), Digest::of(b"raw output"));
210 assert_eq!(*condensate(false).raw_digest(), Digest::of(b"raw output"));
211 }
212
213 #[test]
214 fn lossy_flag_tells_a_consumer_whether_to_fetch_the_original() {
215 assert!(condensate(true).needs_raw_for_completeness());
216 assert!(!condensate(false).needs_raw_for_completeness());
217 }
218
219 #[test]
220 fn payload_may_be_stored_instead_of_inlined() {
221 let big = Condensate::new(
222 SchemaId::new("boundgraph.ast.v1", 1),
223 CondensatePayload::Stored(Digest::of(b"summary")),
224 Digest::of(b"raw"),
225 8_000,
226 true,
227 SlicerId::new("ast", 2),
228 );
229 assert!(!big.payload.is_inline());
230 assert_eq!(
231 big.payload,
232 CondensatePayload::Stored(Digest::of(b"summary"))
233 );
234 }
235
236 #[test]
237 fn is_typed_not_a_string() {
238 // The payload round-trips as structure, not as prose.
239 let k = Condensate::new(
240 SchemaId::new("boundgraph.testrun.v1", 1),
241 CondensatePayload::inline(&(3u32, vec!["test_a", "test_b"])).unwrap(),
242 Digest::of(b"raw"),
243 200,
244 false,
245 SlicerId::new("json", 1),
246 );
247 let CondensatePayload::Inline(v) = &k.payload else {
248 panic!("expected inline payload");
249 };
250 let (failures, names): (u32, Vec<String>) = v.deserialized().unwrap();
251 assert_eq!(failures, 3);
252 assert_eq!(names, vec!["test_a", "test_b"]);
253 }
254
255 #[test]
256 fn slicer_version_participates_in_identity() {
257 let mut other = condensate(true);
258 other.slicer = SlicerId::new("log", 2);
259 assert_ne!(
260 condensate(true).digest().unwrap(),
261 other.digest().unwrap(),
262 "a different slicer version is a different condensate"
263 );
264 }
265
266 #[test]
267 fn round_trips_through_canonical_cbor() {
268 let k = condensate(true);
269 let bytes = crate::to_canonical_cbor(&k).unwrap();
270 let back: Condensate = crate::from_canonical_cbor(&bytes).unwrap();
271 assert_eq!(k, back);
272 }
273}