Skip to main content

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}