Files
clawhdf5/crates/clawhdf5-migrate/src/main.rs
T
Omar Sobh b08df7b628 clawmates: phase work
Mission: 01a00c41-bac0-7eb3-a8c8-8b7044f3086d
Phase: 01a00c41-bac2-71e3-a58b-c473421200ee

Committed by the ClawMates delivery pipeline from the agents' working tree. Authored by agents, not by the named committer.
2026-08-16 20:44:28 +00:00

962 lines
34 KiB
Rust

mod hdf5_reader;
mod hdf5_writer;
mod sqlite_reader;
mod validate;
use clap::Parser;
use sqlite_reader::SchemaConfig;
/// Migrate ZeroClaw agent memory from SQLite to HDF5 format.
#[derive(Parser, Debug)]
#[command(name = "clawhdf5-migrate", version, about)]
struct Cli {
/// Source SQLite database path
#[arg(long)]
sqlite: String,
/// Destination HDF5 file path
#[arg(long)]
hdf5: String,
/// Agent ID for metadata
#[arg(long, default_value = "migrated")]
agent_id: String,
/// Embedder name for metadata
#[arg(long, default_value = "unknown")]
embedder: String,
/// Embedding dimension (auto-detect from first row if not specified)
#[arg(long)]
embedding_dim: Option<usize>,
/// Skip deleted/tombstoned entries
#[arg(long)]
skip_deleted: bool,
/// Enable deflate compression on embeddings
#[arg(long)]
compression: bool,
/// Compression level 1-9
#[arg(long, default_value_t = 4)]
compression_level: u32,
/// Store embeddings as float16 (halves storage)
#[arg(long)]
float16: bool,
/// Validate without writing
#[arg(long)]
dry_run: bool,
/// Content-check every migrated row (default: a representative sample)
#[arg(long)]
validate_full: bool,
/// Append only rows newer than the existing output (by chunk id), merging
/// into the file at --hdf5 if it exists
#[arg(long)]
incremental: bool,
/// Override the SQLite table name for memory chunks
#[arg(long)]
chunks_table: Option<String>,
/// Override the SQLite table name for sessions
#[arg(long)]
sessions_table: Option<String>,
/// Override the SQLite table name for entities
#[arg(long)]
entities_table: Option<String>,
/// Override the SQLite table name for relations
#[arg(long)]
relations_table: Option<String>,
/// Print progress
#[arg(long)]
verbose: bool,
}
/// Build the schema config from CLI table-name overrides (defaults otherwise).
fn schema_from_cli(cli: &Cli) -> SchemaConfig {
let mut c = SchemaConfig::default();
if let Some(t) = &cli.chunks_table {
c.chunks.table = t.clone();
}
if let Some(t) = &cli.sessions_table {
c.sessions.table = t.clone();
}
if let Some(t) = &cli.entities_table {
c.entities.table = t.clone();
}
if let Some(t) = &cli.relations_table {
c.relations.table = t.clone();
}
c
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let schema = schema_from_cli(&cli);
// Dry run: a fast count-only pass that does not buffer the database.
if cli.dry_run {
let counts = sqlite_reader::read_counts(&cli.sqlite, cli.skip_deleted, &schema)?;
eprintln!("Dry run — no output file written.");
eprintln!(
"Would migrate: {} chunks, {} sessions, {} entities, {} relations",
counts.chunks, counts.sessions, counts.entities, counts.relations
);
return Ok(());
}
if cli.verbose {
eprintln!("Reading SQLite database: {}", cli.sqlite);
}
// Incremental: merge new rows into the existing output (if present).
let incremental_base = if cli.incremental && std::path::Path::new(&cli.hdf5).exists() {
Some(hdf5_reader::read_hdf5(&cli.hdf5)?)
} else {
None
};
let min_chunk_id = incremental_base
.as_ref()
.map(|d| d.chunks.iter().map(|c| c.id).max().unwrap_or(0))
.unwrap_or(0);
let dim_hint = cli
.embedding_dim
.or_else(|| incremental_base.as_ref().map(|d| d.embedding_dim));
let source = if min_chunk_id > 0 {
sqlite_reader::read_sqlite_filtered(
&cli.sqlite,
cli.skip_deleted,
dim_hint,
&schema,
min_chunk_id,
)?
} else {
sqlite_reader::read_sqlite(&cli.sqlite, cli.skip_deleted, dim_hint, &schema)?
};
// Build the dataset to write: either the source alone, or the existing
// output plus the newly-read rows (metadata groups refreshed from source).
let data = match incremental_base {
Some(mut base) => {
let added = source.chunks.len();
base.chunks.extend(source.chunks);
base.sessions = source.sessions;
base.entities = source.entities;
base.relations = source.relations;
base.embedding_dim = source.embedding_dim.max(base.embedding_dim);
// Carry the current run's real SQLite source forward for
// provenance — `base` (re-read from the prior HDF5 output) has
// no meaningful source_path of its own.
base.source_path = source.source_path;
if cli.verbose {
eprintln!("Incremental: appended {added} new chunks (id > {min_chunk_id})");
}
base
}
None => source,
};
if cli.verbose {
eprintln!(
"Migrating {} chunks, {} sessions, {} entities, {} relations (dim={})",
data.chunks.len(),
data.sessions.len(),
data.entities.len(),
data.relations.len(),
data.embedding_dim
);
eprintln!("Writing HDF5 file: {}", cli.hdf5);
}
let opts = hdf5_writer::WriteOptions {
agent_id: cli.agent_id,
embedder: cli.embedder,
compression: cli.compression,
compression_level: cli.compression_level.clamp(1, 9),
float16: cli.float16,
};
hdf5_writer::write_hdf5(&cli.hdf5, &data, &opts)?;
if cli.verbose {
eprintln!("Validating output (content check)...");
}
let summary = validate::validate_hdf5(&cli.hdf5, &data, cli.validate_full, cli.float16)?;
eprintln!(
"Migration complete: {} chunks, {} sessions, {} entities, {} relations (dim={}); {} rows content-verified",
summary.chunks,
summary.sessions,
summary.entities,
summary.relations,
summary.embedding_dim,
summary.rows_checked,
);
if summary.provenance_verified {
eprintln!("Provenance: chunks/text and chunks/embeddings SHA-256 hashes verified.");
} else if cli.verbose {
eprintln!("Provenance: no provenance hash found to verify (older output format?).");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use tempfile::TempDir;
/// Create a test SQLite database with the ZeroClaw schema.
fn create_test_db(dir: &TempDir) -> String {
let db_path = dir.path().join("test.db");
let path_str = db_path.to_str().unwrap().to_string();
let conn = Connection::open(&path_str).unwrap();
conn.execute_batch(
"CREATE TABLE memory_chunks (
id INTEGER PRIMARY KEY,
chunk TEXT NOT NULL,
embedding BLOB NOT NULL,
source_channel TEXT DEFAULT 'api',
timestamp REAL NOT NULL,
session_id TEXT,
tags TEXT DEFAULT '',
deleted INTEGER DEFAULT 0
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
start_idx INTEGER,
end_idx INTEGER,
channel TEXT,
timestamp REAL,
summary TEXT
);
CREATE TABLE entities (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
embedding_idx INTEGER DEFAULT -1
);
CREATE TABLE relations (
src INTEGER NOT NULL,
tgt INTEGER NOT NULL,
relation TEXT NOT NULL,
weight REAL DEFAULT 1.0,
timestamp REAL,
FOREIGN KEY (src) REFERENCES entities(id),
FOREIGN KEY (tgt) REFERENCES entities(id)
);",
)
.unwrap();
path_str
}
/// Insert a memory chunk with a known embedding.
fn insert_chunk(conn: &Connection, id: i64, text: &str, embedding: &[f32], deleted: i32) {
let blob: Vec<u8> = embedding.iter().flat_map(|v| v.to_le_bytes()).collect();
conn.execute(
"INSERT INTO memory_chunks (id, chunk, embedding, source_channel, timestamp, session_id, tags, deleted)
VALUES (?1, ?2, ?3, 'api', 1700000000.0, 'sess-1', 'tag1,tag2', ?4)",
rusqlite::params![id, text, blob, deleted],
)
.unwrap();
}
fn insert_session(conn: &Connection, id: &str, start: i64, end: i64) {
conn.execute(
"INSERT INTO sessions (id, start_idx, end_idx, channel, timestamp, summary)
VALUES (?1, ?2, ?3, 'discord', 1700000000.0, 'test summary')",
rusqlite::params![id, start, end],
)
.unwrap();
}
fn insert_entity(conn: &Connection, id: i64, name: &str, etype: &str) {
conn.execute(
"INSERT INTO entities (id, name, type, embedding_idx) VALUES (?1, ?2, ?3, -1)",
rusqlite::params![id, name, etype],
)
.unwrap();
}
fn insert_relation(conn: &Connection, src: i64, tgt: i64, rel: &str) {
conn.execute(
"INSERT INTO relations (src, tgt, relation, weight, timestamp)
VALUES (?1, ?2, ?3, 1.0, 1700000000.0)",
rusqlite::params![src, tgt, rel],
)
.unwrap();
}
fn make_embedding(dim: usize, seed: f32) -> Vec<f32> {
(0..dim).map(|i| seed + i as f32 * 0.1).collect()
}
// ---------- Test 1: Basic end-to-end migration ----------
#[test]
fn test_basic_migration() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "hello world", &make_embedding(8, 1.0), 0);
insert_chunk(&conn, 2, "goodbye world", &make_embedding(8, 2.0), 0);
insert_session(&conn, "s1", 0, 1);
insert_entity(&conn, 1, "Alice", "person");
insert_relation(&conn, 1, 1, "self");
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "test-agent".into(),
embedder: "test-embed".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 2);
assert_eq!(summary.sessions, 1);
assert_eq!(summary.entities, 1);
assert_eq!(summary.relations, 1);
assert_eq!(summary.embedding_dim, 8);
}
// ---------- Test 2: Skip deleted rows ----------
#[test]
fn test_skip_deleted() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "active", &make_embedding(4, 1.0), 0);
insert_chunk(&conn, 2, "deleted", &make_embedding(4, 2.0), 1);
insert_chunk(&conn, 3, "also active", &make_embedding(4, 3.0), 0);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, true, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 2);
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 2);
}
// ---------- Test 3: Include deleted rows ----------
#[test]
fn test_include_deleted() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "active", &make_embedding(4, 1.0), 0);
insert_chunk(&conn, 2, "deleted", &make_embedding(4, 2.0), 1);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 2);
}
// ---------- Test 4: Auto-detect embedding dimension ----------
#[test]
fn test_auto_detect_dim() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.embedding_dim, 16);
}
// ---------- Test 5: Manual embedding dimension ----------
#[test]
fn test_manual_dim() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, Some(8), &SchemaConfig::default()).unwrap();
assert_eq!(data.embedding_dim, 8);
// Embedding truncated to dim 8
assert_eq!(data.chunks[0].embedding.len(), 8);
}
// ---------- Test 6: Float16 conversion ----------
#[test]
fn test_float16_conversion() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
let emb = vec![1.0f32, 2.5, -0.5, 3.125];
insert_chunk(&conn, 1, "test", &emb, 0);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: true,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// Content-validate with the float16 tolerance enabled.
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, true, true).unwrap();
assert_eq!(summary.chunks, 1);
// Verify float16 values are within tolerance
for &v in &emb {
let f16 = half::f16::from_f32(v);
let roundtrip = f16.to_f32();
assert!(
(v - roundtrip).abs() < 0.01,
"f16 roundtrip too lossy for {v}"
);
}
}
// ---------- Test 7: Compression produces valid file ----------
#[test]
fn test_compression() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_compressed = dir.path().join("compressed.h5");
let h5_uncompressed = dir.path().join("uncompressed.h5");
let conn = Connection::open(&db_path).unwrap();
// Insert enough data so compression can be effective
for i in 0..100 {
insert_chunk(&conn, i, &format!("chunk {i}"), &make_embedding(32, 0.0), 0);
}
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts_compressed = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: true,
compression_level: 6,
float16: false,
};
hdf5_writer::write_hdf5(h5_compressed.to_str().unwrap(), &data, &opts_compressed).unwrap();
let opts_plain = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_uncompressed.to_str().unwrap(), &data, &opts_plain).unwrap();
let sz_c = std::fs::metadata(&h5_compressed).unwrap().len();
let sz_u = std::fs::metadata(&h5_uncompressed).unwrap().len();
assert!(
sz_c < sz_u,
"Compressed ({sz_c}) should be smaller than uncompressed ({sz_u})"
);
}
// ---------- Test 8: Dry run doesn't create file ----------
#[test]
fn test_dry_run() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("should_not_exist.h5");
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "test", &make_embedding(4, 1.0), 0);
drop(conn);
// Simulate dry-run: read data but don't write
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 1);
assert!(!h5_path.exists());
}
// ---------- Test 9: Empty database migration ----------
#[test]
fn test_empty_db() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 0);
assert_eq!(data.sessions.len(), 0);
assert_eq!(data.entities.len(), 0);
assert_eq!(data.relations.len(), 0);
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 0);
}
// ---------- Test 10: Large migration (1000 entries) ----------
#[test]
fn test_large_migration() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
for i in 0..1000 {
insert_chunk(
&conn,
i,
&format!("chunk number {i} with some text"),
&make_embedding(64, i as f32 * 0.01),
0,
);
}
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 1000);
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 1000);
}
// ---------- Test 11: Session migration ----------
#[test]
fn test_session_migration() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
insert_session(&conn, "session-alpha", 0, 10);
insert_session(&conn, "session-beta", 11, 20);
insert_session(&conn, "session-gamma", 21, 30);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.sessions.len(), 3);
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.sessions, 3);
}
// ---------- Test 12: Knowledge graph (entities + relations) ----------
#[test]
fn test_knowledge_graph_migration() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
insert_entity(&conn, 1, "Alice", "person");
insert_entity(&conn, 2, "Bob", "person");
insert_entity(&conn, 3, "Rust", "language");
insert_relation(&conn, 1, 2, "knows");
insert_relation(&conn, 1, 3, "uses");
insert_relation(&conn, 2, 3, "uses");
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.entities.len(), 3);
assert_eq!(data.relations.len(), 3);
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.entities, 3);
assert_eq!(summary.relations, 3);
}
// ---------- Test 13: Validation catches chunk count mismatch ----------
#[test]
fn test_validation_catches_count_mismatch() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "test", &make_embedding(4, 1.0), 0);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// Validating against a source with an extra (unwritten) chunk must fail.
let mut bigger =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let mut extra = bigger.chunks[0].clone();
extra.id = 999;
bigger.chunks.push(extra);
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &bigger, false, false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("count mismatch"));
}
// ---------- Test 14: Metadata attributes are stored ----------
#[test]
fn test_metadata_attributes() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "test", &make_embedding(8, 1.0), 0);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "my-agent-42".into(),
embedder: "openai-ada".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let file = clawhdf5::File::open(h5_path.to_str().unwrap()).unwrap();
let root = file.root();
let attrs = root.attrs().unwrap();
match attrs.get("agent_id") {
Some(clawhdf5_format::type_builders::AttrValue::String(s)) => {
assert_eq!(s, "my-agent-42");
}
other => panic!("Expected String agent_id, got {other:?}"),
}
match attrs.get("embedder") {
Some(clawhdf5_format::type_builders::AttrValue::String(s)) => {
assert_eq!(s, "openai-ada");
}
other => panic!("Expected String embedder, got {other:?}"),
}
match attrs.get("embedding_dim") {
Some(clawhdf5_format::type_builders::AttrValue::I64(d)) => {
assert_eq!(*d, 8);
}
other => panic!("Expected I64 embedding_dim, got {other:?}"),
}
}
// ---------- Test 15: Embedding values roundtrip correctly ----------
#[test]
fn test_embedding_roundtrip() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
let emb = vec![0.1, 0.2, 0.3, 0.4];
insert_chunk(&conn, 1, "test", &emb, 0);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let file = clawhdf5::File::open(h5_path.to_str().unwrap()).unwrap();
let chunks_group = file.group("chunks").unwrap();
let emb_ds = chunks_group.dataset("embeddings").unwrap();
let read_back = emb_ds.read_f32().unwrap();
assert_eq!(read_back.len(), 4);
for (a, b) in emb.iter().zip(read_back.iter()) {
assert!((a - b).abs() < 1e-6, "Embedding mismatch: {a} vs {b}");
}
}
// ---------- Test 16: Full combined migration ----------
#[test]
fn test_full_combined_migration() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
for i in 0..5 {
insert_chunk(
&conn,
i,
&format!("chunk {i}"),
&make_embedding(16, i as f32),
if i == 3 { 1 } else { 0 },
);
}
insert_session(&conn, "s1", 0, 2);
insert_session(&conn, "s2", 3, 4);
insert_entity(&conn, 1, "Alice", "person");
insert_entity(&conn, 2, "Bob", "person");
insert_relation(&conn, 1, 2, "knows");
drop(conn);
// Skip deleted
let data =
sqlite_reader::read_sqlite(&db_path, true, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 4); // chunk 3 is deleted
let opts = hdf5_writer::WriteOptions {
agent_id: "combined-test".into(),
embedder: "test-embedder".into(),
compression: true,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 4);
assert_eq!(summary.sessions, 2);
assert_eq!(summary.entities, 2);
assert_eq!(summary.relations, 1);
assert_eq!(summary.embedding_dim, 16);
}
// ---------- Test 17: Validation catches session mismatch ----------
#[test]
fn test_validation_session_mismatch() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
insert_session(&conn, "s1", 0, 10);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// Validating against a source whose session content differs must fail.
let mut tampered =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
tampered.sessions[0].summary = "DIFFERENT".into();
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &tampered, false, false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("session"));
}
// ---------- Real content validation catches corrupt embeddings ----------
#[test]
fn test_content_validation_catches_embedding_corruption() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "hello", &make_embedding(8, 1.0), 0);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// A source whose embedding differs (but counts match) must fail validation.
let mut tampered =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
tampered.chunks[0].embedding[3] += 9.0;
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &tampered, true, false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("embedding"));
}
// ---------- Configurable schema: custom table names ----------
#[test]
fn test_configurable_table_names() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("custom.db");
let path_str = db_path.to_str().unwrap().to_string();
let conn = Connection::open(&path_str).unwrap();
// Chunks live in a differently-named table; the others use defaults.
conn.execute_batch(
"CREATE TABLE my_chunks (
id INTEGER PRIMARY KEY, chunk TEXT, embedding BLOB,
source_channel TEXT, timestamp REAL, session_id TEXT, tags TEXT, deleted INTEGER
);
CREATE TABLE sessions (id TEXT, start_idx INTEGER, end_idx INTEGER, channel TEXT, timestamp REAL, summary TEXT);
CREATE TABLE entities (id INTEGER, name TEXT, type TEXT, embedding_idx INTEGER);
CREATE TABLE relations (src INTEGER, tgt INTEGER, relation TEXT, weight REAL, timestamp REAL);",
)
.unwrap();
let blob: Vec<u8> = make_embedding(4, 1.0)
.iter()
.flat_map(|v| v.to_le_bytes())
.collect();
conn.execute(
"INSERT INTO my_chunks VALUES (1, 'hi', ?1, 'api', 1.0, 's', '', 0)",
rusqlite::params![blob],
)
.unwrap();
drop(conn);
let mut schema = SchemaConfig::default();
schema.chunks.table = "my_chunks".into();
let data = sqlite_reader::read_sqlite(&path_str, false, None, &schema).unwrap();
assert_eq!(data.chunks.len(), 1);
assert_eq!(data.chunks[0].chunk, "hi");
assert_eq!(data.embedding_dim, 4);
// Counts pass should also honor the custom table name.
let counts = sqlite_reader::read_counts(&path_str, false, &schema).unwrap();
assert_eq!(counts.chunks, 1);
}
// ---------- Incremental migration appends only new rows ----------
#[test]
fn test_incremental_migration() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let cfg = SchemaConfig::default();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
// First migration: 2 chunks.
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "one", &make_embedding(4, 1.0), 0);
insert_chunk(&conn, 2, "two", &make_embedding(4, 2.0), 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None, &cfg).unwrap();
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// Add two more rows, then migrate incrementally.
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 3, "three", &make_embedding(4, 3.0), 0);
insert_chunk(&conn, 4, "four", &make_embedding(4, 4.0), 0);
drop(conn);
let base = hdf5_reader::read_hdf5(h5_path.to_str().unwrap()).unwrap();
let max_id = base.chunks.iter().map(|c| c.id).max().unwrap_or(0);
assert_eq!(max_id, 2);
let new =
sqlite_reader::read_sqlite_filtered(&db_path, false, Some(4), &cfg, max_id).unwrap();
assert_eq!(new.chunks.len(), 2); // only id 3 and 4
let mut merged = base;
merged.chunks.extend(new.chunks);
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &merged, &opts).unwrap();
let final_data = hdf5_reader::read_hdf5(h5_path.to_str().unwrap()).unwrap();
assert_eq!(final_data.chunks.len(), 4);
let texts: Vec<&str> = final_data.chunks.iter().map(|c| c.chunk.as_str()).collect();
assert_eq!(texts, vec!["one", "two", "three", "four"]);
}
}