grit-core 0.2.3

Embedded, bi-temporal property graph for agent memory: one SQLite file, in-process, deterministic
Documentation
//! Quick performance smoke test against the AGENTS.md scale envelope
//! (≤100k nodes / ≤1M edges; traversal ≤10ms p95; hybrid search ≤50ms p95).
//! Not the criterion suite — a fixture generator + wall-clock percentiles to
//! see where we stand. Usage:
//!
//! ```sh
//! cargo run --release -p grit-core --example quickbench -- [nodes] [edges] [episodes]
//! ```

use std::time::Instant;

use grit_core::{Budget, GraphOp, Grit, Options, Query, Traversal};
use uuid::Uuid;

/// Deterministic xorshift so runs are comparable without a rand dependency.
struct Rng(u64);

impl Rng {
    fn next(&mut self) -> u64 {
        self.0 ^= self.0 << 13;
        self.0 ^= self.0 >> 7;
        self.0 ^= self.0 << 17;
        self.0
    }

    fn below(&mut self, n: usize) -> usize {
        (self.next() % n as u64) as usize
    }
}

const WORDS: &[&str] = &[
    "yoneda",
    "functor",
    "kernel",
    "image",
    "exact",
    "sequence",
    "lemma",
    "sheaf",
    "topos",
    "adjoint",
    "limit",
    "colimit",
    "monad",
    "algebra",
    "module",
    "tensor",
    "homology",
    "cohomology",
    "spectral",
    "fibration",
    "bundle",
    "manifold",
    "scheme",
    "variety",
    "ideal",
    "ring",
    "field",
    "group",
    "torsion",
    "lattice",
    "category",
    "morphism",
    "natural",
    "transformation",
    "presheaf",
    "stalk",
    "germ",
    "local",
    "global",
    "section",
    "derived",
    "abelian",
    "simplicial",
    "chain",
    "complex",
    "boundary",
    "cycle",
    "closed",
    "open",
    "dense",
    "compact",
    "hausdorff",
    "metric",
    "norm",
    "banach",
    "hilbert",
    "operator",
    "spectrum",
    "eigenvalue",
    "duality",
];

fn percentiles(mut samples: Vec<f64>) -> (f64, f64, f64) {
    samples.sort_by(f64::total_cmp);
    let pick = |q: f64| samples[((samples.len() - 1) as f64 * q) as usize];
    (pick(0.50), pick(0.95), *samples.last().unwrap())
}

fn arg(n: usize, default: usize) -> usize {
    std::env::args()
        .nth(n)
        .and_then(|s| s.parse().ok())
        .unwrap_or(default)
}

fn main() -> Result<(), grit_core::Error> {
    let n_nodes = arg(1, 100_000);
    let n_edges = arg(2, 300_000);
    let n_episodes = arg(3, 10_000);
    let n_invalidations = n_edges / 20; // invalidate 5% of edges

    let dir = std::env::temp_dir().join(format!("grit-quickbench-{}", std::process::id()));
    std::fs::create_dir_all(&dir)?;
    let path = dir.join("bench.db");
    let _ = std::fs::remove_file(&path);
    let g = Grit::open(&path, Options::new("bench"))?;
    let mut rng = Rng(0x9E37_79B9_7F4A_7C15);

    println!(
        "fixture: {n_nodes} nodes, {n_edges} edges, {n_episodes} episodes, {n_invalidations} invalidations"
    );
    println!("db: {}\n", path.display());

    // ---- write path ----
    let t = Instant::now();
    let mut node_ids: Vec<Uuid> = Vec::with_capacity(n_nodes);
    for i in 0..n_nodes {
        let id = g.new_id();
        let (w1, w2, w3, w4) = (
            WORDS[rng.below(WORDS.len())],
            WORDS[rng.below(WORDS.len())],
            WORDS[rng.below(WORDS.len())],
            WORDS[rng.below(WORDS.len())],
        );
        g.apply(GraphOp::AddNode {
            id,
            kind: "concept".into(),
            name: format!("{w1} {w2} {i}"),
            summary: format!("the {w3} of the {w4} in context {i}"),
            attrs: serde_json::json!({}),
            group_id: format!("g{}", i % 8),
        })?;
        node_ids.push(id);
    }
    let dt = t.elapsed().as_secs_f64();
    println!(
        "insert nodes:      {:>9.0} ops/s  ({dt:.1}s)",
        n_nodes as f64 / dt
    );

    let now_ms = 1_752_000_000_000i64; // roughly "now"; only relative order matters
    let t = Instant::now();
    let mut edge_ids: Vec<Uuid> = Vec::with_capacity(n_edges);
    for i in 0..n_edges {
        let id = g.new_id();
        let src = node_ids[rng.below(n_nodes)];
        let dst = node_ids[rng.below(n_nodes)];
        let (w1, w2) = (WORDS[rng.below(WORDS.len())], WORDS[rng.below(WORDS.len())]);
        g.apply(GraphOp::AddEdge {
            id,
            src,
            dst,
            rel: "RELATES".into(),
            fact: format!("the {w1} constrains the {w2} ({i})"),
            attrs: serde_json::json!({}),
            group_id: format!("g{}", i % 8),
            valid_at: Some(now_ms - (rng.below(1_000_000_000) as i64)),
            invalid_at: None,
        })?;
        edge_ids.push(id);
    }
    let dt = t.elapsed().as_secs_f64();
    println!(
        "insert edges:      {:>9.0} ops/s  ({dt:.1}s)",
        n_edges as f64 / dt
    );

    let t = Instant::now();
    for i in 0..n_episodes {
        let (w1, w2, w3) = (
            WORDS[rng.below(WORDS.len())],
            WORDS[rng.below(WORDS.len())],
            WORDS[rng.below(WORDS.len())],
        );
        g.apply(GraphOp::AddEpisode {
            id: g.new_id(),
            source: "bench".into(),
            kind: String::new(),
            content: format!("today we discussed the {w1} and how the {w2} affects the {w3}"),
            occurred_at: now_ms - i as i64,
            group_id: format!("g{}", i % 8),
            mentions: vec![node_ids[rng.below(n_nodes)], edge_ids[rng.below(n_edges)]],
        })?;
    }
    let dt = t.elapsed().as_secs_f64();
    println!(
        "insert episodes:   {:>9.0} ops/s  ({dt:.1}s)",
        n_episodes as f64 / dt
    );

    let t = Instant::now();
    for _ in 0..n_invalidations {
        g.apply(GraphOp::InvalidateEdge {
            edge_id: edge_ids[rng.below(n_edges)],
            invalid_at: now_ms - (rng.below(500_000_000) as i64),
        })?;
    }
    let dt = t.elapsed().as_secs_f64();
    println!(
        "invalidate edges:  {:>9.0} ops/s  ({dt:.1}s)\n",
        n_invalidations as f64 / dt
    );

    // ---- read path ----
    const ITERS: usize = 200;

    let mut samples = Vec::with_capacity(ITERS);
    let mut reached = 0usize;
    for _ in 0..ITERS {
        let seed = node_ids[rng.below(n_nodes)];
        let t = Instant::now();
        let sub = g.traverse(&[seed], &Traversal::default().depth(3))?;
        samples.push(t.elapsed().as_secs_f64() * 1e3);
        reached += sub.nodes.len();
    }
    let (p50, p95, max) = percentiles(samples);
    println!(
        "traverse depth-3:  p50 {p50:7.2}ms  p95 {p95:7.2}ms  max {max:7.2}ms  \
         (avg {} nodes reached, default 256 budget; target p95 ≤ 10ms)",
        reached / ITERS
    );

    let mut samples = Vec::with_capacity(ITERS);
    let mut reached = 0usize;
    for _ in 0..ITERS {
        let seed = node_ids[rng.below(n_nodes)];
        let t = Instant::now();
        let sub = g.traverse(
            &[seed],
            &Traversal::default().depth(3).max_nodes(usize::MAX),
        )?;
        samples.push(t.elapsed().as_secs_f64() * 1e3);
        reached += sub.nodes.len();
    }
    let (p50, p95, max) = percentiles(samples);
    println!(
        "traverse (no cap): p50 {p50:7.2}ms  p95 {p95:7.2}ms  max {max:7.2}ms  \
         (avg {} nodes reached, unbounded)",
        reached / ITERS
    );

    let mut samples = Vec::with_capacity(ITERS);
    for _ in 0..ITERS {
        let seed = node_ids[rng.below(n_nodes)];
        let t = Instant::now();
        g.traverse(
            &[seed],
            &Traversal::default().depth(3).as_at(now_ms - 250_000_000),
        )?;
        samples.push(t.elapsed().as_secs_f64() * 1e3);
    }
    let (p50, p95, max) = percentiles(samples);
    println!("traverse (as_at):  p50 {p50:7.2}ms  p95 {p95:7.2}ms  max {max:7.2}ms  (time travel)");

    let mut samples = Vec::with_capacity(ITERS);
    let mut hits_total = 0usize;
    for _ in 0..ITERS {
        let (w1, w2) = (WORDS[rng.below(WORDS.len())], WORDS[rng.below(WORDS.len())]);
        let t = Instant::now();
        let hits = g.search(Query::text(format!("{w1} {w2}")).budget(Budget::items(20)))?;
        samples.push(t.elapsed().as_secs_f64() * 1e3);
        hits_total += hits.len();
    }
    let (p50, p95, max) = percentiles(samples);
    println!(
        "hybrid search:     p50 {p50:7.2}ms  p95 {p95:7.2}ms  max {max:7.2}ms  \
         (avg {} hits; target p95 ≤ 50ms)",
        hits_total / ITERS
    );

    let mut samples = Vec::with_capacity(ITERS);
    for _ in 0..ITERS {
        let id = node_ids[rng.below(n_nodes)];
        let t = Instant::now();
        g.node_history(id)?;
        samples.push(t.elapsed().as_secs_f64() * 1e3);
    }
    let (p50, p95, max) = percentiles(samples);
    println!("node_history:      p50 {p50:7.2}ms  p95 {p95:7.2}ms  max {max:7.2}ms");

    let stats = g.stats()?;
    drop(g); // checkpoint WAL before measuring size
    let size = std::fs::metadata(&path)?.len() as f64 / 1e6;
    println!(
        "\nfinal: {} nodes, {} edges, {} episodes, {} oplog entries, {size:.0} MB on disk",
        stats.nodes, stats.edges, stats.episodes, stats.oplog
    );
    Ok(())
}