klieo-memory-pgvector 3.8.2

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::{DummyEmbedder, FakeEmbedder, MemoryPgvector, 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(DummyEmbedder))
        .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(_)));
}