klieo-memory-pgvector 3.12.1

PostgreSQL + pgvector implementation of klieo-core's LongTermMemory.
Documentation
//! Postgres + pgvector round-trip integration test. `#[ignore]`d because it
//! needs Docker (the `pgvector/pgvector` image) — same convention as the
//! repo's other testcontainer-backed suites. The pure helpers (scope columns,
//! vector literal, id parsing, undefined-table detection) are covered by the
//! in-crate unit tests; this drives the DB boundary methods end-to-end.
//!
//! Run:  cargo test --manifest-path crates/klieo-memory-pgvector/Cargo.toml -- --ignored

use klieo_core::error::MemoryError;
use klieo_core::memory::{Fact, Scope};
use klieo_memory_graph::FilterableLongTermMemory;
use klieo_memory_pgvector::{FakeEmbedder, MemoryPgvector, NonRankingEmbedder, PgvectorConfig};
use std::sync::Arc;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::AsyncRunner;
use testcontainers::{ContainerAsync, GenericImage, ImageExt};

async fn start_pgvector() -> (ContainerAsync<GenericImage>, String) {
    let container = GenericImage::new("pgvector/pgvector", "pg16")
        .with_wait_for(WaitFor::message_on_stderr(
            "database system is ready to accept connections",
        ))
        .with_exposed_port(5432.tcp())
        .with_env_var("POSTGRES_PASSWORD", "test")
        .with_env_var("POSTGRES_DB", "klieo")
        .start()
        .await
        .expect("start pgvector container");
    let port = container
        .get_host_port_ipv4(5432)
        .await
        .expect("mapped port");
    let url = format!("postgres://postgres:test@127.0.0.1:{port}/klieo");
    (container, url)
}

#[tokio::test]
#[ignore = "requires Docker (pgvector/pgvector image)"]
async fn remember_recall_filter_forget_roundtrip() {
    let (_container, url) = start_pgvector().await;

    let mem = MemoryPgvector::new(PgvectorConfig::new(url), Arc::new(NonRankingEmbedder))
        .await
        .expect("connect + pool");
    let scope = Scope::Workspace("ws-1".to_string());

    let id_alpha = mem
        .long_term
        .remember(scope.clone(), Fact::new("alpha fact"))
        .await
        .expect("remember alpha");
    let id_beta = mem
        .long_term
        .remember(scope.clone(), Fact::new("beta fact"))
        .await
        .expect("remember beta");

    let recalled = mem
        .long_term
        .recall(scope.clone(), "alpha", 10)
        .await
        .expect("recall");
    assert!(recalled.iter().any(|f| f.text == "alpha fact"));

    let filtered = mem
        .pgvector_long_term
        .recall_filtered(scope.clone(), "beta", 10, std::slice::from_ref(&id_beta))
        .await
        .expect("recall_filtered");
    assert!(filtered.iter().all(|f| f.text == "beta fact"));

    mem.long_term.forget(id_alpha).await.expect("forget alpha");
    let after = mem
        .long_term
        .recall(scope, "alpha", 10)
        .await
        .expect("recall after forget");
    assert!(!after.iter().any(|f| f.text == "alpha fact"));
}

#[tokio::test]
#[ignore = "requires Docker (pgvector/pgvector image)"]
async fn existing_table_dim_mismatch_fails_closed() {
    let (_container, url) = start_pgvector().await;
    let scope = Scope::Workspace("dim".to_string());

    // First embedder creates the table with a vector(64) column.
    let mem64 = MemoryPgvector::new(
        PgvectorConfig::new(url.clone()),
        Arc::new(FakeEmbedder::new(64)),
    )
    .await
    .expect("connect 64");
    mem64
        .long_term
        .remember(scope.clone(), Fact::new("hi"))
        .await
        .expect("remember 64");

    // A different embedder dimension must be refused, not written mismatched.
    let mem128 = MemoryPgvector::new(PgvectorConfig::new(url), Arc::new(FakeEmbedder::new(128)))
        .await
        .expect("connect 128");
    let err = mem128
        .long_term
        .remember(scope, Fact::new("hi"))
        .await
        .expect_err("dimension mismatch must fail closed");
    assert!(matches!(err, MemoryError::Embedding(_)));
}

/// Holds this backend to `klieo_core::conformance`.
///
/// Uses `FakeEmbedder` rather than the `NonRankingEmbedder` the round-trip test
/// above uses: `NonRankingEmbedder` returns an all-zero vector for every text, so
/// every fact embeds identically and no ordering property could discriminate.
/// `FakeEmbedder` hashes the text, so an exact match ranks above an unrelated
/// fact — a real assertion about cosine ordering.
///
/// A fresh container per test gives the empty-scope precondition for free.
#[tokio::test]
#[ignore = "requires Docker (pgvector/pgvector image)"]
async fn satisfies_long_term_conformance() {
    use klieo_core::conformance::{self, ExpectedOrdering, Scopes};

    let (_container, url) = start_pgvector().await;
    let mem = MemoryPgvector::new(PgvectorConfig::new(url), Arc::new(FakeEmbedder::new(384)))
        .await
        .expect("connect + pool");
    let query = "pgvector conformance relevance probe";

    conformance::long_term_memory(
        mem.long_term.as_ref(),
        &Scopes::agent("pgvector-conformance-primary", "pgvector-conformance-other"),
        ExpectedOrdering::Relevance {
            nearer: query.to_string(),
            farther: "an entirely unrelated fact about diesel maintenance".to_string(),
            query: query.to_string(),
        },
    )
    .await;
}