use super::*;
use crate::shared::embed_calibration::{Calibration, SimilarityScale};
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub struct ReembedRow {
pub rowid: i64,
pub id: Uuid,
pub partition: Uuid,
pub text: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ReembedPending {
pub rag: usize,
pub attachments: usize,
pub notes: usize,
}
#[allow(dead_code)]
fn rows_to_reembed(
conn: &Connection,
table: &str,
partition_col: &str,
text_col: &str,
limit: usize,
) -> Result<Vec<ReembedRow>> {
let generation = current_embed_gen(conn)?;
let sql = format!(
"SELECT rowid, id, {partition_col}, {text_col} FROM {table}
WHERE IFNULL(embed_gen, ?1) <> ?2
ORDER BY rowid LIMIT ?3"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![NULL_EMBED_GEN, generation, limit as i64], |r| {
Ok(ReembedRow {
rowid: r.get(0)?,
id: parse_uuid(r.get::<_, String>(1)?),
partition: parse_uuid(r.get::<_, String>(2)?),
text: r.get(3)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
fn count_to_reembed(conn: &Connection, table: &str) -> Result<usize> {
let generation = current_embed_gen(conn)?;
let sql = format!("SELECT COUNT(*) FROM {table} WHERE IFNULL(embed_gen, ?1) <> ?2");
let n: i64 = conn.query_row(&sql, params![NULL_EMBED_GEN, generation], |r| r.get(0))?;
Ok(n as usize)
}
const NOTES_TO_REEMBED_PREDICATE: &str = "FROM notes n
LEFT JOIN note_vectors v ON v.note_id = n.id
LEFT JOIN note_superseded s ON s.note_id = n.id
WHERE s.note_id IS NULL
AND (v.note_id IS NULL OR IFNULL(v.embed_gen, ?1) <> ?2)";
impl Db {
#[allow(dead_code)] pub fn embed_generation(&self) -> Result<u32> {
let conn = self.conn.lock().unwrap();
current_embed_gen(&conn)
}
pub fn bump_embed_generation(&self) -> Result<u32> {
let conn = self.conn.lock().unwrap();
let next = current_embed_gen(&conn)?.saturating_add(1);
meta_set(&conn, KEY_EMBED_GEN, &next.to_string())?;
Ok(next)
}
pub fn embed_calibration(&self) -> Result<Option<Calibration>> {
let conn = self.conn.lock().unwrap();
read_calibration(&conn)
}
pub fn set_embed_calibration(&self, c: &Calibration) -> Result<()> {
let conn = self.conn.lock().unwrap();
meta_set(&conn, KEY_EMBED_CAL_UNRELATED, &c.unrelated.to_string())?;
meta_set(&conn, KEY_EMBED_CAL_PARAPHRASE, &c.paraphrase.to_string())
}
pub fn similarity_scale(&self) -> SimilarityScale {
self.embed_calibration()
.ok()
.flatten()
.map(SimilarityScale::from_calibration)
.unwrap_or_else(SimilarityScale::identity)
}
}
fn read_calibration(conn: &Connection) -> Result<Option<Calibration>> {
let read = |key: &str| -> Result<Option<f32>> {
Ok(meta_get(conn, key)?.and_then(|v| v.parse::<f32>().ok()))
};
Ok(
match (
read(KEY_EMBED_CAL_UNRELATED)?,
read(KEY_EMBED_CAL_PARAPHRASE)?,
) {
(Some(unrelated), Some(paraphrase)) => Some(Calibration {
unrelated,
paraphrase,
}),
_ => None,
},
)
}
pub(super) fn clear_calibration(conn: &Connection) -> Result<()> {
meta_del(conn, KEY_EMBED_CAL_UNRELATED)?;
meta_del(conn, KEY_EMBED_CAL_PARAPHRASE)
}
#[allow(dead_code)]
impl Db {
pub fn rag_rows_to_reembed(&self, limit: usize) -> Result<Vec<ReembedRow>> {
let conn = self.conn.lock().unwrap();
rows_to_reembed(&conn, "rag_documents", "profile_id", "chunk_text", limit)
}
pub fn attachment_rows_to_reembed(&self, limit: usize) -> Result<Vec<ReembedRow>> {
let conn = self.conn.lock().unwrap();
rows_to_reembed(
&conn,
"attachment_documents",
"chat_id",
"chunk_text",
limit,
)
}
pub fn notes_to_reembed(&self, limit: usize) -> Result<Vec<ReembedRow>> {
let conn = self.conn.lock().unwrap();
let generation = current_embed_gen(&conn)?;
let sql = format!(
"SELECT n.rowid, n.id, n.profile_id, n.content
{NOTES_TO_REEMBED_PREDICATE}
ORDER BY n.rowid LIMIT ?3"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![NULL_EMBED_GEN, generation, limit as i64], |r| {
Ok(ReembedRow {
rowid: r.get(0)?,
id: parse_uuid(r.get::<_, String>(1)?),
partition: parse_uuid(r.get::<_, String>(2)?),
text: r.get(3)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn count_rows_to_reembed(&self) -> Result<ReembedPending> {
let conn = self.conn.lock().unwrap();
let generation = current_embed_gen(&conn)?;
let notes: i64 = conn.query_row(
&format!("SELECT COUNT(*) {NOTES_TO_REEMBED_PREDICATE}"),
params![NULL_EMBED_GEN, generation],
|r| r.get(0),
)?;
Ok(ReembedPending {
rag: count_to_reembed(&conn, "rag_documents")?,
attachments: count_to_reembed(&conn, "attachment_documents")?,
notes: notes as usize,
})
}
pub fn rag_set_vector(&self, rowid: i64, profile_id: Uuid, embedding: &[f32]) -> Result<()> {
let conn = self.conn.lock().unwrap();
set_vector(
&conn,
VectorTarget {
docs: "rag_documents",
vectors: "rag_vectors",
partition_col: "profile_id",
},
rowid,
profile_id,
embedding,
ensure_vec_table,
)
}
pub fn attachment_set_vector(
&self,
rowid: i64,
chat_id: Uuid,
embedding: &[f32],
) -> Result<()> {
let conn = self.conn.lock().unwrap();
set_vector(
&conn,
VectorTarget {
docs: "attachment_documents",
vectors: "attachment_vectors",
partition_col: "chat_id",
},
rowid,
chat_id,
embedding,
ensure_attachment_vec_table,
)
}
pub fn drop_vector_tables(&self) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DROP TABLE IF EXISTS rag_vectors", [])?;
conn.execute("DROP TABLE IF EXISTS attachment_vectors", [])?;
meta_del(&conn, KEY_RAG_DIM)?;
Ok(())
}
}
#[allow(dead_code)]
struct VectorTarget {
docs: &'static str,
vectors: &'static str,
partition_col: &'static str,
}
#[allow(dead_code)]
fn set_vector(
conn: &Connection,
target: VectorTarget,
rowid: i64,
partition: Uuid,
embedding: &[f32],
ensure_table: fn(&Connection, usize) -> Result<()>,
) -> Result<()> {
ensure_table(conn, embedding.len())?;
let VectorTarget {
docs,
vectors,
partition_col,
} = target;
let exists: Option<i64> = conn
.query_row(
&format!("SELECT 1 FROM {docs} WHERE rowid = ?1 AND {partition_col} = ?2"),
params![rowid, partition.to_string()],
|r| r.get(0),
)
.optional()?;
if exists.is_none() {
return Ok(());
}
conn.execute(
&format!("DELETE FROM {vectors} WHERE rowid = ?1"),
params![rowid],
)?;
conn.execute(
&format!("INSERT INTO {vectors}(rowid, {partition_col}, embedding) VALUES (?1, ?2, ?3)"),
params![
rowid,
partition.to_string(),
bytemuck::cast_slice::<f32, u8>(embedding),
],
)?;
conn.execute(
&format!("UPDATE {docs} SET embed_gen = ?1 WHERE rowid = ?2"),
params![current_embed_gen(conn)?, rowid],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Db {
Db::open_in_memory().unwrap()
}
fn chunk(chat: Uuid, att: Uuid, text: &str, v: Vec<f32>) -> AttachmentChunk {
AttachmentChunk::new(chat, att, "a.txt", text, v)
}
fn null_out(db: &Db, table: &str) {
let conn = db.conn.lock().unwrap();
conn.execute_batch(&format!("UPDATE {table} SET embed_gen = NULL"))
.unwrap();
}
#[test]
fn generation_starts_at_one_and_increments() {
let db = db();
assert_eq!(db.embed_generation().unwrap(), 1, "fresh DB");
assert_eq!(db.bump_embed_generation().unwrap(), 2);
assert_eq!(db.embed_generation().unwrap(), 2);
assert_eq!(db.bump_embed_generation().unwrap(), 3);
assert_eq!(db.embed_generation().unwrap(), 3);
}
#[test]
fn generation_survives_reopening_the_db() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.db");
Db::open(&path).unwrap().bump_embed_generation().unwrap();
assert_eq!(Db::open(&path).unwrap().embed_generation().unwrap(), 2);
}
#[test]
fn corrupt_generation_reads_as_the_first() {
let db = db();
{
let conn = db.conn.lock().unwrap();
meta_set(&conn, KEY_EMBED_GEN, "not a number").unwrap();
}
assert_eq!(db.embed_generation().unwrap(), 1);
}
#[test]
fn a_written_vector_is_stamped_and_turns_foreign_after_a_bump() {
let db = db();
let (p, chat) = (Uuid::new_v4(), Uuid::new_v4());
let note = Note::new(p, "n", vec![]);
db.note_insert(¬e).unwrap();
db.note_vector_upsert(note.id, p, &[1.0, 0.0]).unwrap();
db.rag_insert(&RagDocument::new(p, "s", "chunk", vec![1.0, 0.0]))
.unwrap();
db.attachment_insert(&chunk(chat, Uuid::new_v4(), "frag", vec![1.0, 0.0]))
.unwrap();
assert_eq!(
db.count_rows_to_reembed().unwrap(),
ReembedPending::default()
);
db.bump_embed_generation().unwrap();
assert_eq!(
db.count_rows_to_reembed().unwrap(),
ReembedPending {
rag: 1,
attachments: 1,
notes: 1
},
"every store reads as foreign after a bump"
);
}
fn cal(unrelated: f32, paraphrase: f32) -> Calibration {
Calibration {
unrelated,
paraphrase,
}
}
#[test]
fn calibration_round_trips_and_replaces() {
let db = db();
assert_eq!(db.embed_calibration().unwrap(), None, "fresh DB");
let c = cal(0.7897, 0.9456);
db.set_embed_calibration(&c).unwrap();
assert_eq!(db.embed_calibration().unwrap(), Some(c));
let other = cal(0.4128, 0.8176);
db.set_embed_calibration(&other).unwrap();
assert_eq!(db.embed_calibration().unwrap(), Some(other));
}
#[test]
fn calibration_survives_reopening_the_db() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.db");
let c = cal(0.7897, 0.9456);
Db::open(&path).unwrap().set_embed_calibration(&c).unwrap();
assert_eq!(
Db::open(&path).unwrap().embed_calibration().unwrap(),
Some(c)
);
}
#[test]
fn an_unreadable_calibration_reads_as_absent() {
let db = db();
db.set_embed_calibration(&cal(0.4, 0.8)).unwrap();
{
let conn = db.conn.lock().unwrap();
meta_set(&conn, KEY_EMBED_CAL_UNRELATED, "not a number").unwrap();
}
assert_eq!(db.embed_calibration().unwrap(), None);
let half = Db::open_in_memory().unwrap();
{
let conn = half.conn.lock().unwrap();
meta_set(&conn, KEY_EMBED_CAL_PARAPHRASE, "0.9456").unwrap();
}
assert_eq!(db.embed_calibration().unwrap(), None);
}
#[test]
fn similarity_scale_is_the_identity_until_something_is_recorded() {
let db = db();
for t in [0.85, 0.72, 0.62] {
assert_eq!(db.similarity_scale().map(t), t);
}
db.set_embed_calibration(&cal(0.7897, 0.9456)).unwrap();
assert!((db.similarity_scale().map(0.72) - 0.908).abs() < 0.002);
}
#[test]
fn reset_vectors_forgets_the_calibration_but_dropping_the_tables_keeps_it() {
let db = db();
db.set_embed_calibration(&cal(0.7897, 0.9456)).unwrap();
db.reset_vectors().unwrap();
assert_eq!(db.embed_calibration().unwrap(), None);
assert_eq!(db.similarity_scale().map(0.72), 0.72, "back to identity");
let kept = Db::open_in_memory().unwrap();
let c = cal(0.7897, 0.9456);
kept.set_embed_calibration(&c).unwrap();
kept.drop_vector_tables().unwrap();
assert_eq!(kept.embed_calibration().unwrap(), Some(c));
}
#[test]
fn null_generation_reads_as_foreign_everywhere() {
let db = db();
let (p, chat, att) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
let note = Note::new(p, "n", vec![]);
db.note_insert(¬e).unwrap();
db.note_vector_upsert(note.id, p, &[1.0, 0.0]).unwrap();
db.rag_insert(&RagDocument::new(p, "s", "chunk", vec![1.0, 0.0]))
.unwrap();
db.attachment_insert(&chunk(chat, att, "frag", vec![1.0, 0.0]))
.unwrap();
null_out(&db, "note_vectors");
null_out(&db, "rag_documents");
null_out(&db, "attachment_documents");
assert_eq!(db.notes_to_reembed(10).unwrap().len(), 1);
assert_eq!(db.rag_rows_to_reembed(10).unwrap().len(), 1);
assert_eq!(db.attachment_rows_to_reembed(10).unwrap().len(), 1);
assert_eq!(
db.count_rows_to_reembed().unwrap(),
ReembedPending {
rag: 1,
attachments: 1,
notes: 1
}
);
assert_eq!(db.notes_missing_vectors(p).unwrap().len(), 1);
assert!(
db.note_search_semantic(p, &[1.0, 0.0], 5)
.unwrap()
.is_empty()
);
assert!(db.notes_with_vectors(p).unwrap().is_empty());
assert!(db.attachment_indexed_ids(chat).unwrap().is_empty());
assert!(
db.attachment_search(chat, &[1.0, 0.0], 5)
.unwrap()
.is_empty()
);
}
#[test]
fn queue_carries_the_partition_and_the_text() {
let db = db();
let (p, chat, att) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
let doc = RagDocument::new(p, "s", "the chunk text", vec![1.0, 0.0]);
let doc_id = doc.id;
db.rag_insert(&doc).unwrap();
db.attachment_insert(&chunk(chat, att, "the fragment", vec![1.0, 0.0]))
.unwrap();
let note = Note::new(p, "the note body", vec![]);
db.note_insert(¬e).unwrap();
db.bump_embed_generation().unwrap();
let rag = db.rag_rows_to_reembed(10).unwrap();
assert_eq!(rag.len(), 1);
assert_eq!(rag[0].partition, p, "RAG is partitioned by profile");
assert_eq!(rag[0].id, doc_id);
assert_eq!(rag[0].text, "the chunk text");
let atts = db.attachment_rows_to_reembed(10).unwrap();
assert_eq!(atts[0].partition, chat, "attachments by chat");
assert_eq!(atts[0].text, "the fragment");
let notes = db.notes_to_reembed(10).unwrap();
assert_eq!(notes[0].partition, p);
assert_eq!(notes[0].id, note.id, "keyed by note id, not rowid");
assert_eq!(notes[0].text, "the note body");
}
#[test]
fn queue_spans_every_profile_and_chat() {
let db = db();
let (a, b) = (Uuid::new_v4(), Uuid::new_v4());
db.rag_insert(&RagDocument::new(a, "s", "x", vec![1.0, 0.0]))
.unwrap();
db.rag_insert(&RagDocument::new(b, "s", "y", vec![0.0, 1.0]))
.unwrap();
db.attachment_insert(&chunk(a, Uuid::new_v4(), "x", vec![1.0, 0.0]))
.unwrap();
db.attachment_insert(&chunk(b, Uuid::new_v4(), "y", vec![0.0, 1.0]))
.unwrap();
for p in [a, b] {
let n = Note::new(p, "n", vec![]);
db.note_insert(&n).unwrap();
}
db.bump_embed_generation().unwrap();
assert_eq!(
db.count_rows_to_reembed().unwrap(),
ReembedPending {
rag: 2,
attachments: 2,
notes: 2
}
);
assert_eq!(db.rag_rows_to_reembed(10).unwrap().len(), 2);
assert_eq!(db.attachment_rows_to_reembed(10).unwrap().len(), 2);
assert_eq!(db.notes_to_reembed(10).unwrap().len(), 2);
}
#[test]
fn batches_are_stable_and_a_resumed_job_makes_progress() {
let db = db();
let p = Uuid::new_v4();
for i in 0..5 {
db.rag_insert(&RagDocument::new(
p,
"s",
format!("chunk {i}"),
vec![1.0, 0.0],
))
.unwrap();
}
db.bump_embed_generation().unwrap();
let first = db.rag_rows_to_reembed(2).unwrap();
assert_eq!(first.len(), 2);
assert_eq!(first, db.rag_rows_to_reembed(2).unwrap(), "stable order");
assert!(first[0].rowid < first[1].rowid, "oldest rowid first");
for row in &first {
db.rag_set_vector(row.rowid, row.partition, &[0.0, 1.0])
.unwrap();
}
assert_eq!(db.count_rows_to_reembed().unwrap().rag, 3);
let second = db.rag_rows_to_reembed(2).unwrap();
assert!(second.iter().all(|r| !first.contains(r)), "no repeats");
for row in db.rag_rows_to_reembed(100).unwrap() {
db.rag_set_vector(row.rowid, row.partition, &[0.0, 1.0])
.unwrap();
}
assert_eq!(
db.count_rows_to_reembed().unwrap(),
ReembedPending::default()
);
}
#[test]
fn superseded_notes_are_not_queued() {
let db = db();
let p = Uuid::new_v4();
let old = Note::new(p, "old", vec![]);
let new = Note::new(p, "new", vec![]);
db.note_insert(&old).unwrap();
db.note_insert(&new).unwrap();
db.note_supersede_mark(p, old.id, new.id).unwrap();
db.bump_embed_generation().unwrap();
let queued = db.notes_to_reembed(10).unwrap();
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].id, new.id);
assert_eq!(
db.count_rows_to_reembed().unwrap().notes,
queued.len(),
"the count uses the same predicate as the list"
);
}
#[test]
fn set_vector_replaces_stamps_and_keeps_the_rowid() {
let db = db();
let (p, chat, att) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
db.rag_insert(&RagDocument::new(p, "s", "chunk", vec![1.0, 0.0]))
.unwrap();
db.attachment_insert(&chunk(chat, att, "frag", vec![1.0, 0.0]))
.unwrap();
db.bump_embed_generation().unwrap();
let rag = db.rag_rows_to_reembed(10).unwrap().remove(0);
db.rag_set_vector(rag.rowid, rag.partition, &[0.0, 1.0])
.unwrap();
let atts = db.attachment_rows_to_reembed(10).unwrap().remove(0);
db.attachment_set_vector(atts.rowid, atts.partition, &[0.0, 1.0])
.unwrap();
assert_eq!(
db.count_rows_to_reembed().unwrap(),
ReembedPending::default()
);
let same_rowid: i64 = {
let conn = db.conn.lock().unwrap();
conn.query_row("SELECT rowid FROM rag_documents", [], |r| r.get(0))
.unwrap()
};
assert_eq!(same_rowid, rag.rowid);
let hits = db.rag_search(p, &[0.0, 1.0], 5).unwrap();
assert_eq!(hits.len(), 1, "replaced, not duplicated");
assert_eq!(hits[0].id, rag.id);
assert!(hits[0].distance < 0.001, "matches the new vector");
assert_eq!(db.attachment_indexed_ids(chat).unwrap(), vec![att]);
let hits = db.attachment_search(chat, &[0.0, 1.0], 5).unwrap();
assert_eq!(hits.len(), 1);
assert!(hits[0].distance < 0.001);
}
#[test]
fn set_vector_skips_a_row_that_is_gone_or_foreign() {
let db = db();
let (a, b) = (Uuid::new_v4(), Uuid::new_v4());
db.rag_insert(&RagDocument::new(a, "s", "chunk", vec![1.0, 0.0]))
.unwrap();
db.bump_embed_generation().unwrap();
let row = db.rag_rows_to_reembed(10).unwrap().remove(0);
db.rag_set_vector(row.rowid, b, &[0.0, 1.0]).unwrap();
assert!(db.rag_search(b, &[0.0, 1.0], 5).unwrap().is_empty());
db.rag_delete_by_source(a, "s").unwrap();
db.rag_set_vector(row.rowid, a, &[0.0, 1.0]).unwrap();
assert!(db.rag_search(a, &[0.0, 1.0], 5).unwrap().is_empty());
assert_eq!(db.rag_count(a).unwrap(), 0);
}
#[test]
fn set_vector_refuses_a_dimension_mismatch_without_stamping() {
let db = db();
let p = Uuid::new_v4();
db.rag_insert(&RagDocument::new(p, "s", "chunk", vec![1.0, 0.0]))
.unwrap();
db.bump_embed_generation().unwrap();
let row = db.rag_rows_to_reembed(10).unwrap().remove(0);
assert!(db.rag_set_vector(row.rowid, p, &[1.0, 0.0, 0.0]).is_err());
assert_eq!(
db.count_rows_to_reembed().unwrap().rag,
1,
"still outstanding — the failure left no false progress"
);
}
#[test]
fn set_vector_works_at_a_new_dimensionality_after_the_tables_are_dropped() {
let db = db();
let (p, chat, att) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
db.rag_insert(&RagDocument::new(p, "s", "chunk", vec![1.0, 0.0]))
.unwrap();
db.attachment_insert(&chunk(chat, att, "frag", vec![1.0, 0.0]))
.unwrap();
db.bump_embed_generation().unwrap();
db.drop_vector_tables().unwrap();
assert_eq!(db.rag_dimension().unwrap(), None);
let rag = db.rag_rows_to_reembed(10).unwrap().remove(0);
let atts = db.attachment_rows_to_reembed(10).unwrap().remove(0);
assert_eq!(rag.text, "chunk");
assert_eq!(atts.text, "frag");
db.rag_set_vector(rag.rowid, rag.partition, &[0.0, 1.0, 0.0])
.unwrap();
db.attachment_set_vector(atts.rowid, atts.partition, &[0.0, 1.0, 0.0])
.unwrap();
assert_eq!(
db.rag_dimension().unwrap(),
Some(3),
"both tables recreated at the new size"
);
assert_eq!(db.rag_search(p, &[0.0, 1.0, 0.0], 5).unwrap().len(), 1);
assert_eq!(
db.attachment_search(chat, &[0.0, 1.0, 0.0], 5)
.unwrap()
.len(),
1
);
assert_eq!(
db.count_rows_to_reembed().unwrap(),
ReembedPending::default()
);
}
#[test]
fn drop_vector_tables_keeps_everything_reset_vectors_would_discard() {
let db = db();
let (p, chat, att) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
db.rag_insert(&RagDocument::new(p, "s", "chunk", vec![1.0, 0.0]))
.unwrap();
db.attachment_insert(&chunk(chat, att, "frag", vec![1.0, 0.0]))
.unwrap();
let fp = crate::shared::embed_identity::EmbedFingerprint::new(
vec![1.0, 0.0],
Some("bge-m3".into()),
"none",
);
db.set_embed_fingerprint(&fp).unwrap();
db.set_rag_stale_profiles(&[p]).unwrap();
db.bump_embed_generation().unwrap();
db.drop_vector_tables().unwrap();
assert_eq!(db.rag_count(p).unwrap(), 1, "document rows kept");
assert_eq!(
db.attachment_rows_to_reembed(10).unwrap()[0].text,
"frag",
"attachment rows kept — unlike reset_vectors, which deletes them"
);
assert_eq!(
db.embed_fingerprint().unwrap(),
Some(fp),
"fingerprint kept"
);
assert!(db.rag_is_stale(p).unwrap(), "stale marks kept");
assert!(db.rag_search(p, &[1.0, 0.0], 5).unwrap().is_empty());
assert!(
db.attachment_search(chat, &[1.0, 0.0], 5)
.unwrap()
.is_empty()
);
}
#[test]
fn a_bump_hides_note_vectors_and_the_backfill_restores_search() {
let db = db();
let (a, b) = (Uuid::new_v4(), Uuid::new_v4());
let n1 = Note::new(a, "n1", vec![]);
let n2 = Note::new(a, "n2", vec![]);
let nb = Note::new(b, "other profile", vec![]);
for n in [&n1, &n2, &nb] {
db.note_insert(n).unwrap();
}
db.note_vector_upsert(n1.id, a, &[1.0, 0.0]).unwrap();
db.note_vector_upsert(n2.id, a, &[0.0, 1.0]).unwrap();
db.note_vector_upsert(nb.id, b, &[1.0, 1.0]).unwrap();
db.bump_embed_generation().unwrap();
assert_eq!(db.note_list(a, None, &[], None).unwrap().len(), 2);
assert_eq!(db.note_list(b, None, &[], None).unwrap().len(), 1);
assert!(
db.note_search_semantic(a, &[1.0, 0.0], 5)
.unwrap()
.is_empty()
);
assert_eq!(db.notes_missing_vectors(a).unwrap().len(), 2);
assert_eq!(db.notes_missing_vectors(b).unwrap().len(), 1);
db.note_vector_upsert(n1.id, a, &[1.0, 0.0]).unwrap();
assert_eq!(db.note_search_semantic(a, &[1.0, 0.0], 5).unwrap().len(), 1);
assert_eq!(db.notes_missing_vectors(a).unwrap().len(), 1);
}
#[test]
fn a_bump_empties_the_attachment_index_until_it_is_re_embedded() {
let db = db();
let (chat, att) = (Uuid::new_v4(), Uuid::new_v4());
db.attachment_insert(&chunk(chat, att, "frag", vec![1.0, 0.0]))
.unwrap();
assert_eq!(db.attachment_indexed_ids(chat).unwrap(), vec![att]);
db.bump_embed_generation().unwrap();
assert!(db.attachment_indexed_ids(chat).unwrap().is_empty());
assert!(
db.attachment_search(chat, &[1.0, 0.0], 5)
.unwrap()
.is_empty(),
"the vectors are still there, but they are from another model"
);
let row = db.attachment_rows_to_reembed(10).unwrap().remove(0);
assert_eq!(row.text, "frag");
db.attachment_set_vector(row.rowid, row.partition, &[0.0, 1.0])
.unwrap();
assert_eq!(db.attachment_indexed_ids(chat).unwrap(), vec![att]);
assert_eq!(db.attachment_search(chat, &[0.0, 1.0], 5).unwrap().len(), 1);
}
#[test]
fn switching_back_to_the_previous_model_needs_no_work() {
let db = db();
let p = Uuid::new_v4();
let note = Note::new(p, "n", vec![]);
db.note_insert(¬e).unwrap();
db.note_vector_upsert(note.id, p, &[1.0, 0.0]).unwrap();
let first = db.embed_generation().unwrap();
db.bump_embed_generation().unwrap();
assert!(
db.note_search_semantic(p, &[1.0, 0.0], 5)
.unwrap()
.is_empty()
);
{
let conn = db.conn.lock().unwrap();
meta_set(&conn, KEY_EMBED_GEN, &first.to_string()).unwrap();
}
assert_eq!(db.note_search_semantic(p, &[1.0, 0.0], 5).unwrap().len(), 1);
assert_eq!(
db.count_rows_to_reembed().unwrap(),
ReembedPending::default()
);
}
#[test]
fn the_added_column_survives_reopening_and_keeps_the_data() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.db");
let (p, chat) = (Uuid::new_v4(), Uuid::new_v4());
{
let db = Db::open(&path).unwrap();
let note = Note::new(p, "n", vec![]);
db.note_insert(¬e).unwrap();
db.note_vector_upsert(note.id, p, &[1.0, 0.0]).unwrap();
db.rag_insert(&RagDocument::new(p, "s", "chunk", vec![1.0, 0.0]))
.unwrap();
db.attachment_insert(&chunk(chat, Uuid::new_v4(), "frag", vec![1.0, 0.0]))
.unwrap();
}
let db = Db::open(&path).unwrap();
assert_eq!(db.note_list(p, None, &[], None).unwrap().len(), 1);
assert_eq!(db.rag_count(p).unwrap(), 1);
assert_eq!(db.attachment_indexed_ids(chat).unwrap().len(), 1);
assert_eq!(
db.count_rows_to_reembed().unwrap(),
ReembedPending::default(),
"the stamps written by the first run survived"
);
assert_eq!(db.note_search_semantic(p, &[1.0, 0.0], 5).unwrap().len(), 1);
}
#[test]
fn add_column_if_missing_is_idempotent() {
let db = db();
let conn = db.conn.lock().unwrap();
assert!(column_exists(&conn, "rag_documents", "embed_gen").unwrap());
assert!(!column_exists(&conn, "rag_documents", "nope").unwrap());
add_column_if_missing(&conn, "rag_documents", "embed_gen", "INTEGER").unwrap();
}
}