use fathomdb_embedder_api::{Embedder, EmbedderError, EmbedderIdentity, Vector};
use fathomdb_engine::{
DenseReadiness, Engine, InitialState, PreparedWrite, ProjectionRole, ProjectionSpec,
ProjectionVector, SourceId,
};
use fathomdb_schema::SQLITE_SUFFIX;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
#[derive(Clone, Debug)]
struct CountingEmbedder {
identity: EmbedderIdentity,
calls: Arc<AtomicUsize>,
delay_ms: Arc<AtomicU64>,
}
impl CountingEmbedder {
fn new() -> Self {
Self {
identity: EmbedderIdentity::new("deterministic", "rev-a", 384),
calls: Arc::new(AtomicUsize::new(0)),
delay_ms: Arc::new(AtomicU64::new(0)),
}
}
}
impl Embedder for CountingEmbedder {
fn identity(&self) -> EmbedderIdentity {
self.identity.clone()
}
fn embed(&self, _text: &str) -> Result<Vector, EmbedderError> {
self.calls.fetch_add(1, Ordering::SeqCst);
let delay = self.delay_ms.load(Ordering::SeqCst);
if delay > 0 {
std::thread::sleep(Duration::from_millis(delay));
}
let mut v = vec![0.0_f32; self.identity.dimension as usize];
v[0] = 1.0;
Ok(v)
}
}
fn db_path(dir: &TempDir, name: &str) -> PathBuf {
dir.path().join(format!("{name}{SQLITE_SUFFIX}"))
}
fn roles(rs: &[ProjectionRole]) -> BTreeSet<ProjectionRole> {
rs.iter().copied().collect()
}
fn filterable_vector_spec(name: &str) -> ProjectionSpec {
ProjectionSpec {
name: name.to_string(),
roles: roles(&[ProjectionRole::Filterable]),
fts: None,
vector: Some(ProjectionVector { embedder: None, dense_readiness: None }),
source: None,
}
}
fn filterable_only_spec(name: &str) -> ProjectionSpec {
ProjectionSpec {
name: name.to_string(),
roles: roles(&[ProjectionRole::Filterable]),
fts: None,
vector: None,
source: None,
}
}
fn declare_legacy_filterable_vector(
engine: &Engine,
path: &Path,
name: &str,
) -> fathomdb_engine::ProjectionDelta {
assert_eq!(
engine.configure_projections(&[filterable_vector_spec(name)], &[]).expect_err(
"R-20-SV: a `vector` sub-object without `searchable` is now an invalid spec"
),
fathomdb_engine::EngineError::WriteValidation,
);
let delta = engine
.configure_projections(&[filterable_only_spec(name)], &[])
.expect("the `filterable` half is still a valid declaration");
legacy_add_vector_subobject(path, name);
delta
}
fn legacy_add_vector_subobject(path: &Path, name: &str) {
let conn = rusqlite::Connection::open(path).expect("open rw");
let n = conn
.execute(
"UPDATE _fathomdb_projection_registry SET vector_declared = 1 WHERE name = ?1",
[name],
)
.expect("legacy vector sub-object");
assert_eq!(n, 1, "the registry row must exist before the legacy sub-object is added");
}
fn searchable_vector_spec(name: &str) -> ProjectionSpec {
ProjectionSpec {
name: name.to_string(),
roles: roles(&[ProjectionRole::Searchable]),
fts: None,
vector: Some(ProjectionVector { embedder: None, dense_readiness: None }),
source: None,
}
}
fn node(kind: &str, logical_id: &str, body_json: &str) -> PreparedWrite {
PreparedWrite::Node {
kind: kind.to_string(),
body: body_json.to_string(),
source_id: SourceId::new("test:fixture").expect("source id"),
logical_id: Some(logical_id.to_string()),
state: InitialState::Active,
reason: None,
valid_from: None,
valid_until: None,
}
}
fn ro(path: &Path) -> rusqlite::Connection {
rusqlite::Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
)
.expect("open read-only")
}
fn active_cursor(conn: &rusqlite::Connection, logical_id: &str) -> i64 {
conn.query_row(
"SELECT write_cursor FROM canonical_nodes
WHERE logical_id = ?1 AND superseded_at IS NULL",
[logical_id],
|r| r.get::<_, i64>(0),
)
.expect("active cursor")
}
fn vector_row_exists(conn: &rusqlite::Connection, cursor: i64) -> bool {
conn.query_row(
"SELECT COUNT(*) FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
[cursor],
|r| r.get::<_, i64>(0),
)
.expect("vector row probe")
> 0
}
fn vec0_row_exists(conn: &rusqlite::Connection, cursor: i64) -> bool {
conn.query_row("SELECT COUNT(*) FROM vector_default WHERE rowid = ?1", [cursor], |r| {
r.get::<_, i64>(0)
})
.unwrap_or(0)
> 0
}
fn vector_kind_registered(conn: &rusqlite::Connection, kind: &str) -> bool {
conn.query_row("SELECT COUNT(*) FROM _fathomdb_vector_kinds WHERE kind = ?1", [kind], |r| {
r.get::<_, i64>(0)
})
.expect("vector kind probe")
> 0
}
fn eav_values(conn: &rusqlite::Connection, attr_name: &str) -> Vec<String> {
let mut stmt = conn
.prepare(
"SELECT attr_value FROM canonical_attributes WHERE attr_name = ?1 ORDER BY attr_value",
)
.expect("prepare eav probe");
let v: Vec<String> = stmt
.query_map([attr_name], |r| r.get::<_, String>(0))
.expect("eav query")
.map(|r| r.expect("eav row"))
.collect();
v
}
#[test]
fn a_filterable_vector_declaration_backfills_nothing_and_embeds_nothing() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "tc71_forward_backfill");
let embedder = CountingEmbedder::new();
let calls = Arc::clone(&embedder.calls);
let delay_ms = Arc::clone(&embedder.delay_ms);
let opened = Engine::open_with_embedder_for_test(&path, Arc::new(embedder)).expect("open");
let engine = &opened.engine;
engine.write(&[node("doc", "N1", r#"{"summary":"a dense meaning"}"#)]).expect("write N1");
delay_ms.store(8_000, Ordering::SeqCst);
let spec = filterable_vector_spec("summary");
let delta = declare_legacy_filterable_vector(engine, &path, "summary");
assert_eq!(
delta.built,
vec!["summary".to_string()],
"the `filterable` role still builds its EAV projection"
);
assert!(
delta.deferred.is_empty(),
"0.8.20 Slice 23 (R-20-SV): the declaration that reaches the verb no longer CARRIES the \
`vector` sub-object — it is refused. The legacy half is added at rest, exactly as a \
pre-Slice-23 database carries it, and is still REPORTED by `read_projections` below \
(post-5b) — the round-trip contract this assertion originally protected"
);
assert!(!delta.unchanged, "a fresh declaration is not a no-op");
let after = engine
.configure_projections(&[], &[])
.expect("an empty request still runs the declare-time fork");
assert!(after.unchanged, "…and diffs to a no-op");
let conn = ro(&path);
let c1 = active_cursor(&conn, "N1");
assert!(
!vector_kind_registered(&conn, "doc"),
"TC-71: the dense arm must be gated on the `searchable` ROLE. A `vector` sub-object \
without it must not enrol the node kind"
);
engine.drain(2_000).expect(
"`drain` must not wait on a declaration with no `searchable` role — with the embedder at \
8 s a 2 s barrier can only return Ok if nothing was enqueued",
);
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"TC-71: `{{roles:[filterable], vector:true}}` is documented INERT, so declaring it must \
not spend a single embed call"
);
let conn = ro(&path);
assert!(
!vector_row_exists(&conn, c1),
"no `_fathomdb_vector_rows` row for an inert projection"
);
assert!(!vec0_row_exists(&conn, c1), "…and no `vector_default` row either");
assert_eq!(
eav_values(&conn, "summary"),
vec!["a dense meaning".to_string()],
"the `filterable` value must still be stored at rest — the fix makes the projection \
inert, it must not make it disappear"
);
let back = engine.read_projections().expect("read_projections");
assert_eq!(
back,
vec![ProjectionSpec {
vector: Some(ProjectionVector {
embedder: None,
dense_readiness: Some(DenseReadiness::Ready),
}),
..spec.clone()
}],
"the declaration round-trips verbatim (plus the engine-set readiness), exactly as the \
Slice-15d `vector_subobject_is_stored_not_built` contract requires"
);
delay_ms.store(0, Ordering::SeqCst);
engine
.configure_projections(&[searchable_vector_spec("meaning")], &[])
.expect("declare a real `searchable→vector` projection");
engine.drain(30_000).expect("drain the real backfill");
assert!(calls.load(Ordering::SeqCst) > 0, "CONTROL: `searchable→vector` still embeds");
let conn = ro(&path);
assert!(
vector_kind_registered(&conn, "doc"),
"CONTROL: `searchable→vector` still enrols the node kind"
);
assert!(vector_row_exists(&conn, c1), "CONTROL: the corpus is still backfilled");
assert!(vec0_row_exists(&conn, c1), "CONTROL: …with a real vec0 row");
opened.engine.close().unwrap();
}
#[test]
fn a_write_under_a_filterable_vector_declaration_enqueues_no_embedding() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "tc71_late_enrolment");
let embedder = CountingEmbedder::new();
let calls = Arc::clone(&embedder.calls);
let delay_ms = Arc::clone(&embedder.delay_ms);
let opened = Engine::open_with_embedder_for_test(&path, Arc::new(embedder)).expect("open");
let engine = &opened.engine;
declare_legacy_filterable_vector(engine, &path, "summary");
let conn = ro(&path);
assert!(
!vector_kind_registered(&conn, "doc"),
"fixture: an empty corpus enrols nothing at declare time, on either code path"
);
delay_ms.store(8_000, Ordering::SeqCst);
engine
.write(&[node("doc", "N1", r#"{"summary":"written after the declaration"}"#)])
.expect("N1");
let conn = ro(&path);
let c1 = active_cursor(&conn, "N1");
assert!(
!vector_kind_registered(&conn, "doc"),
"TC-71 (late enrolment): the write-path door must require the `searchable` role too, or \
the declare-time gate is trivially routed around by writing after declaring"
);
engine.drain(2_000).expect(
"`drain` must not wait on a write made under a declaration with no `searchable` role",
);
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"TC-71 (late enrolment): a write under `{{roles:[filterable], vector:true}}` must not \
enqueue an embedding"
);
let conn = ro(&path);
assert!(!vector_row_exists(&conn, c1), "no vector row for a write under an inert projection");
assert!(!vec0_row_exists(&conn, c1), "…and no vec0 row");
delay_ms.store(0, Ordering::SeqCst);
engine
.configure_projections(
&[ProjectionSpec {
roles: roles(&[ProjectionRole::Filterable, ProjectionRole::Searchable]),
..filterable_vector_spec("summary")
}],
&[],
)
.expect("promote to filterable+searchable");
engine.drain(30_000).expect("drain the promoted backfill");
let conn = ro(&path);
assert!(vector_kind_registered(&conn, "doc"), "CONTROL: adding `searchable` enrols the kind");
assert!(vector_row_exists(&conn, c1), "CONTROL: and backfills the row written while inert");
assert!(calls.load(Ordering::SeqCst) > 0, "CONTROL: the embedder ran once promoted");
opened.engine.close().unwrap();
}
#[test]
fn demoting_a_searchable_vector_projection_to_filterable_un_enrols_the_kind() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "tc71_demotion_inverse");
let embedder = CountingEmbedder::new();
let calls = Arc::clone(&embedder.calls);
let delay_ms = Arc::clone(&embedder.delay_ms);
let opened = Engine::open_with_embedder_for_test(&path, Arc::new(embedder)).expect("open");
let engine = &opened.engine;
engine.write(&[node("doc", "N1", r#"{"summary":"a dense meaning"}"#)]).expect("write N1");
engine.configure_projections(&[searchable_vector_spec("summary")], &[]).expect("configure");
engine.drain(30_000).expect("drain");
let conn = ro(&path);
let c1 = active_cursor(&conn, "N1");
assert!(vector_kind_registered(&conn, "doc"), "fixture: the declaration enrolled `doc`");
assert!(vector_row_exists(&conn, c1), "fixture: N1 is embedded");
assert_eq!(calls.load(Ordering::SeqCst), 1, "fixture: exactly one embed so far");
assert_eq!(
engine
.configure_projections(&[filterable_vector_spec("summary")], &["summary".to_string()])
.expect_err("R-20-SV: the demoted shape is no longer a valid spec"),
fathomdb_engine::EngineError::WriteValidation,
);
let delta = engine
.configure_projections(&[filterable_only_spec("summary")], &["summary".to_string()])
.expect("drop-then-redeclare is the documented path for a destructive role change");
assert!(delta.dropped.contains(&"summary".to_string()), "the drop half is reported");
assert!(delta.built.contains(&"summary".to_string()), "the fresh `filterable` half is built");
legacy_add_vector_subobject(&path, "summary");
engine.configure_projections(&[], &[]).expect("a later governed call is a no-op");
let conn = ro(&path);
assert!(
!vector_kind_registered(&conn, "doc"),
"TC-71: demoting the LAST `searchable→vector` projection to `filterable`+`vector` must \
un-enrol the node kind. Before the fix the surviving `vector_declared = 1` row made the \
post-state read `declared`, so the inverse never fired and the write path kept embedding \
for a projection that no longer puts anything on the dense arm"
);
assert!(
vector_row_exists(&conn, c1),
"un-enrolment must delete NO embedding — the shipped drop arm leaves vectors at rest"
);
assert!(vec0_row_exists(&conn, c1), "…including the vec0 row");
assert_eq!(
eav_values(&conn, "summary"),
vec!["a dense meaning".to_string()],
"the demoted `filterable` projection still stores its value at rest"
);
delay_ms.store(8_000, Ordering::SeqCst);
engine.write(&[node("doc", "N2", r#"{"summary":"written after the demotion"}"#)]).expect("N2");
engine.drain(2_000).expect("`drain` must not wait on a demoted projection's writes");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"a write after the demotion must not be embedded — no `searchable` role, no dense arm"
);
let conn = ro(&path);
let c2 = active_cursor(&conn, "N2");
assert!(!vector_row_exists(&conn, c2), "N2 has no vector: nothing was ever enqueued for it");
assert!(!vector_kind_registered(&conn, "doc"), "…and the kind stayed un-enrolled");
delay_ms.store(0, Ordering::SeqCst);
engine
.configure_projections(&[searchable_vector_spec("summary")], &["summary".to_string()])
.expect("re-promote");
engine.drain(30_000).expect("drain the re-promoted backfill");
let conn = ro(&path);
assert!(vector_kind_registered(&conn, "doc"), "re-promoting re-enrols the kind");
assert!(
vector_row_exists(&conn, c2),
"the row written while the arm was off is backfilled, not stranded"
);
opened.engine.close().unwrap();
}
#[test]
fn dropping_the_last_searchable_vector_projection_un_enrols_past_an_inert_sibling() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "tc71_masked_inverse");
let embedder = CountingEmbedder::new();
let calls = Arc::clone(&embedder.calls);
let delay_ms = Arc::clone(&embedder.delay_ms);
let opened = Engine::open_with_embedder_for_test(&path, Arc::new(embedder)).expect("open");
let engine = &opened.engine;
engine
.write(&[node("doc", "N1", r#"{"summary":"a dense meaning","tag":"alpha"}"#)])
.expect("write N1");
engine
.configure_projections(
&[searchable_vector_spec("summary"), filterable_only_spec("tag")],
&[],
)
.expect("configure both");
engine.drain(30_000).expect("drain");
legacy_add_vector_subobject(&path, "tag");
let conn = ro(&path);
let c1 = active_cursor(&conn, "N1");
assert!(vector_kind_registered(&conn, "doc"), "fixture: the SEARCHABLE one enrolled `doc`");
assert!(vector_row_exists(&conn, c1), "fixture: N1 is embedded");
assert_eq!(calls.load(Ordering::SeqCst), 1, "fixture: exactly one embed so far");
engine
.configure_projections(&[], &["summary".to_string()])
.expect("drop the real vector projection");
let back = engine.read_projections().expect("read_projections");
assert_eq!(back.len(), 1, "the inert sibling survives the drop");
assert_eq!(back[0].name, "tag");
assert!(back[0].vector.is_some(), "…with its `vector` sub-object intact");
let conn = ro(&path);
assert!(
!vector_kind_registered(&conn, "doc"),
"TC-71: an INERT `{{filterable, vector}}` sibling must not mask the drop of the last \
`searchable→vector` projection. Before the fix its surviving `vector_declared = 1` row \
made the post-state read `declared`, so `unenrol_registry_vector_node_kinds` never ran"
);
assert!(vector_row_exists(&conn, c1), "the drop still deletes no embedding");
assert!(vec0_row_exists(&conn, c1), "…including the vec0 row");
delay_ms.store(8_000, Ordering::SeqCst);
engine.write(&[node("doc", "N2", r#"{"summary":"after","tag":"beta"}"#)]).expect("N2");
engine.drain(2_000).expect("`drain` must not wait: no searchable declaration remains");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"with no `searchable→vector` declaration left, a write must embed nothing"
);
let conn = ro(&path);
assert!(!vector_row_exists(&conn, active_cursor(&conn, "N2")), "N2 has no vector");
assert_eq!(
eav_values(&conn, "tag"),
vec!["alpha".to_string(), "beta".to_string()],
"the inert sibling keeps projecting its VALUES throughout — inert, not absent"
);
opened.engine.close().unwrap();
}
#[test]
fn a_filterable_vector_declaration_round_trips_verbatim_without_an_embedder() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "tc71_round_trip_no_embedder");
let opened = Engine::open(path.clone()).expect("open");
let engine = &opened.engine;
engine.write(&[node("doc", "N1", r#"{"summary":"a dense meaning"}"#)]).expect("write N1");
let spec = filterable_vector_spec("summary");
let delta = declare_legacy_filterable_vector(engine, &path, "summary");
assert_eq!(delta.built, vec!["summary".to_string()]);
assert_eq!(
engine.read_projections().expect("read_projections"),
vec![ProjectionSpec {
vector: Some(ProjectionVector {
embedder: None,
dense_readiness: Some(DenseReadiness::Unavailable),
}),
..spec.clone()
}],
"the `vector` sub-object persists verbatim, and its engine-set readiness is unavailable \
without a dense runtime — Slice 23's reject remains a WRITE-path spec validation"
);
assert_eq!(
engine
.configure_projections(std::slice::from_ref(&spec), &[])
.expect_err("re-applying the legacy shape now raises"),
fathomdb_engine::EngineError::WriteValidation,
);
assert!(
matches!(
engine.configure_projections(&[filterable_only_spec("summary")], &[]),
Err(fathomdb_engine::EngineError::ProjectionDestructive { .. })
),
"re-declaring the valid half alone removes the stored `vector` sub-object"
);
let promoted = ProjectionSpec {
roles: roles(&[ProjectionRole::Filterable, ProjectionRole::Searchable]),
..spec.clone()
};
engine
.configure_projections(std::slice::from_ref(&promoted), &[])
.expect("REMEDY 1: adding the `searchable` role is non-destructive and accepted");
let again = engine
.configure_projections(std::slice::from_ref(&promoted), &[])
.expect("re-apply the promoted spec");
assert!(again.unchanged, "re-registering the same VALID spec still diffs to a no-op");
opened.engine.drain(5_000).unwrap();
opened.engine.close().unwrap();
let conn = ro(&path);
assert_eq!(
eav_values(&conn, "summary"),
vec!["a dense meaning".to_string()],
"the value is at rest — inert means no EMBEDDING, not no projection"
);
}