boundgraph-core 0.1.0

Core primitives for the BoundGraph runtime: content-addressed digests, work orders, context, condensates, evidence, and the ledger.
Documentation
//! The Condensate (`κ`) — a size-capped summary that never loses the original.
//!
//! This is the mechanism that turns quadratic context growth into linear
//! growth. A raw tool result might be ten thousand lines; what the next step
//! receives is a few hundred tokens of structured summary.
//!
//! Two properties make that safe rather than merely cheap:
//!
//! **It is typed, not a string.** A condensate carries a [`SchemaId`], so a
//! downstream step can reason about the shape of a result without reading all
//! of it, and caching is sound because equal payloads are genuinely equal.
//!
//! **The original is never destroyed.** [`Condensate::raw`] addresses the
//! complete, unmodified output in the content-addressed store. The model sees
//! the slice; an auditor can always fetch what it was sliced from. Without
//! that, "the agent decided based on this output" would be unverifiable.

use serde::{Deserialize, Serialize};

use crate::digest::Digest;

/// Names the shape of a condensate's payload.
///
/// Versioned, because a slicer that changes its output shape produces a
/// different type of thing, and a consumer must be able to tell.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct SchemaId {
    /// Schema name, e.g. `boundgraph.diff.v1`, `boundgraph.testrun.v1`.
    pub name: String,
    /// Schema version.
    pub version: u32,
}

impl SchemaId {
    /// Name a schema.
    pub fn new(name: impl Into<String>, version: u32) -> Self {
        Self {
            name: name.into(),
            version,
        }
    }
}

/// Which slicer produced a condensate, and at what version.
///
/// Recorded for reproducibility: replaying a run must be able to confirm that
/// re-slicing the same raw output yields the same condensate, and that is only
/// meaningful against a specific slicer version.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct SlicerId {
    /// Slicer name, e.g. `ast`, `log`, `diff`, `json`.
    pub name: String,
    /// Slicer version.
    pub version: u32,
}

impl SlicerId {
    /// Name a slicer.
    pub fn new(name: impl Into<String>, version: u32) -> Self {
        Self {
            name: name.into(),
            version,
        }
    }
}

/// Where the summary itself lives.
///
/// Small payloads are inlined so reading a condensate needs no store lookup;
/// large ones are referenced, so the ledger does not balloon. The threshold is
/// a policy decision made by the slicer, not a property of the type.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CondensatePayload {
    /// The summary, carried directly.
    Inline(ciborium::Value),
    /// The summary, addressed in the content-addressed store.
    Stored(Digest),
}

impl CondensatePayload {
    /// Build an inline payload from any serialisable value.
    ///
    /// # Errors
    ///
    /// Returns [`CanonicalError::Encode`](crate::CanonicalError::Encode) if the
    /// value cannot be represented as CBOR.
    pub fn inline<T: Serialize>(value: &T) -> Result<Self, crate::CanonicalError> {
        Ok(Self::Inline(ciborium::Value::serialized(value).map_err(
            |e| crate::CanonicalError::Encode(e.to_string()),
        )?))
    }

    /// Whether the payload is carried here rather than referenced.
    pub fn is_inline(&self) -> bool {
        matches!(self, Self::Inline(_))
    }
}

/// `κ` — an AST-preserved, size-capped, high-density summary of a result.
///
/// # Example
///
/// ```
/// use boundgraph_core::{Condensate, CondensatePayload, Digest, SchemaId, SlicerId};
///
/// let raw = b"...ten thousand lines of build output...";
///
/// let k = Condensate::new(
///     SchemaId::new("boundgraph.log.v1", 1),
///     CondensatePayload::inline(&"3 errors, first at src/main.rs:42")?,
///     Digest::of(raw),      // the original is still addressable
///     400,                  // the cap that was applied
///     true,                 // slicing dropped information
///     SlicerId::new("log", 1),
/// );
///
/// assert!(k.lossy);
/// assert_eq!(k.raw, Digest::of(raw));
/// # Ok::<(), boundgraph_core::CanonicalError>(())
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Condensate {
    /// The shape of [`payload`](Self::payload). A condensate is typed, never a
    /// bare blob of text.
    pub schema: SchemaId,
    /// The summary itself, inline or stored.
    pub payload: CondensatePayload,
    /// The complete, unmodified output this was derived from.
    ///
    /// Always present, even when nothing was dropped, so an auditor never has
    /// to ask whether the original still exists.
    pub raw: Digest,
    /// The token cap actually applied when slicing.
    pub cap: usize,
    /// Whether slicing dropped information.
    ///
    /// Set honestly by the slicer. A consumer that needs completeness must
    /// check this rather than assume.
    pub lossy: bool,
    /// Which slicer produced this, and at what version.
    pub slicer: SlicerId,
}

impl Condensate {
    /// Construct a condensate.
    pub fn new(
        schema: SchemaId,
        payload: CondensatePayload,
        raw: Digest,
        cap: usize,
        lossy: bool,
        slicer: SlicerId,
    ) -> Self {
        Self {
            schema,
            payload,
            raw,
            cap,
            lossy,
            slicer,
        }
    }

    /// Whether the full original must be fetched to see everything.
    ///
    /// Equivalent to [`lossy`](Self::lossy), named for the question a caller
    /// actually has.
    pub fn needs_raw_for_completeness(&self) -> bool {
        self.lossy
    }

    /// Where to find the complete output.
    pub const fn raw_digest(&self) -> &Digest {
        &self.raw
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::canonical::Canonical;

    fn condensate(lossy: bool) -> Condensate {
        Condensate::new(
            SchemaId::new("boundgraph.log.v1", 1),
            CondensatePayload::inline(&"3 errors").unwrap(),
            Digest::of(b"raw output"),
            400,
            lossy,
            SlicerId::new("log", 1),
        )
    }

    #[test]
    fn carries_schema_payload_raw_cap_lossy_and_slicer() {
        let k = condensate(true);
        assert_eq!(k.schema, SchemaId::new("boundgraph.log.v1", 1));
        assert!(k.payload.is_inline());
        assert_eq!(k.raw, Digest::of(b"raw output"));
        assert_eq!(k.cap, 400);
        assert!(k.lossy);
        assert_eq!(k.slicer, SlicerId::new("log", 1));
    }

    #[test]
    fn the_original_is_always_addressable() {
        // True whether or not anything was dropped.
        assert_eq!(*condensate(true).raw_digest(), Digest::of(b"raw output"));
        assert_eq!(*condensate(false).raw_digest(), Digest::of(b"raw output"));
    }

    #[test]
    fn lossy_flag_tells_a_consumer_whether_to_fetch_the_original() {
        assert!(condensate(true).needs_raw_for_completeness());
        assert!(!condensate(false).needs_raw_for_completeness());
    }

    #[test]
    fn payload_may_be_stored_instead_of_inlined() {
        let big = Condensate::new(
            SchemaId::new("boundgraph.ast.v1", 1),
            CondensatePayload::Stored(Digest::of(b"summary")),
            Digest::of(b"raw"),
            8_000,
            true,
            SlicerId::new("ast", 2),
        );
        assert!(!big.payload.is_inline());
        assert_eq!(
            big.payload,
            CondensatePayload::Stored(Digest::of(b"summary"))
        );
    }

    #[test]
    fn is_typed_not_a_string() {
        // The payload round-trips as structure, not as prose.
        let k = Condensate::new(
            SchemaId::new("boundgraph.testrun.v1", 1),
            CondensatePayload::inline(&(3u32, vec!["test_a", "test_b"])).unwrap(),
            Digest::of(b"raw"),
            200,
            false,
            SlicerId::new("json", 1),
        );
        let CondensatePayload::Inline(v) = &k.payload else {
            panic!("expected inline payload");
        };
        let (failures, names): (u32, Vec<String>) = v.deserialized().unwrap();
        assert_eq!(failures, 3);
        assert_eq!(names, vec!["test_a", "test_b"]);
    }

    #[test]
    fn slicer_version_participates_in_identity() {
        let mut other = condensate(true);
        other.slicer = SlicerId::new("log", 2);
        assert_ne!(
            condensate(true).digest().unwrap(),
            other.digest().unwrap(),
            "a different slicer version is a different condensate"
        );
    }

    #[test]
    fn round_trips_through_canonical_cbor() {
        let k = condensate(true);
        let bytes = crate::to_canonical_cbor(&k).unwrap();
        let back: Condensate = crate::from_canonical_cbor(&bytes).unwrap();
        assert_eq!(k, back);
    }
}