fix(migrate): write a real clawhdf5-agent store

clawhdf5-migrate wrote a layout of its own (/chunks, /sessions,
/entities, /relations, root attributes, no /meta or schema_version) that
HDF5Memory::open rejects, so a "migrated" SQLite database could not be
used as agent memory — contrary to the README.

It now writes through the agent's own API (HDF5Memory::create/open,
save_batch, the session cache and the knowledge graph), so there is no
second copy of the schema:

- sessions and entities/relations carry over; deleted rows become
  deleted records (or are left out with --skip-deleted);
- embeddings follow the library default (float16), --f32 opts out and
  --float16 is a hidden no-op, as in clawhdf5-cli; the `half`-based
  conversion is gone;
- every source row is checked before the output is created: a wrong
  embedding length, an empty embedding, a dimension that differs from
  an existing store's, or a float16 value beyond +-65504 is an error
  naming the chunk id, and an existing store is left untouched;
- --incremental opens the existing store, adds only rows it does not
  hold (matched by content) and follows the source's deleted flags;
- a source with no memory rows needs --embedding-dim;
- validation reads the result back with HDF5Memory::open_read_only,
  compares every field (embeddings bit for bit, round_to_f16 of the
  source for float16) and checks a migrated record is found by search.

clawhdf5-agent gains HDF5Memory::sessions()/sessions_mut(),
HDF5Memory::delete_batch (one save, all-or-nothing, no auto-compact),
SessionCache::add_at, and re-exports SessionCache/SessionEntry.

The old layout's per-dataset SHA-256 provenance attributes have no place
in the agent schema and are gone. An adversarial review found two
blockers (silent truncation of long embeddings; an --incremental
dimension check that could never fire) and four majors (a failed run
wiping the existing store, dim-0 stores, deleted-flag drift); all are
fixed with regression tests. 42 migrate tests, incl. h5py opening a
migrated store.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-24 23:45:15 -05:00
co-authored by Claude Opus 5.5
parent 5c8323cb1e
commit a8fb758489
10 changed files with 1834 additions and 1131 deletions
+115 -1
View File
@@ -73,7 +73,7 @@ pub use ephemeral::{EphemeralEntry, EphemeralStats};
use knowledge::KnowledgeCache;
use memory_strategy::{Exchange, MemoryStrategy, StrategyOutput};
pub use search::SearchOptions;
use session::SessionCache;
pub use session::{SessionCache, SessionEntry};
// --- Error type ---
@@ -1028,6 +1028,18 @@ impl HDF5Memory {
&self.config
}
/// The sessions recorded in this store.
pub fn sessions(&self) -> &SessionCache {
&self.sessions
}
/// Mutable access to the sessions, e.g. to add many at once. Changes
/// reach the disk at the next checkpoint (any flushing call, such as
/// [`HDF5Memory::flush_wal`] or `save_batch`), not immediately.
pub fn sessions_mut(&mut self) -> &mut SessionCache {
&mut self.sessions
}
/// Get a reference to the knowledge cache.
pub fn knowledge(&self) -> &KnowledgeCache {
&self.knowledge
@@ -1401,6 +1413,35 @@ impl HDF5Memory {
}
impl HDF5Memory {
/// Delete many records with a single checkpoint, where
/// [`AgentMemory::delete`] checkpoints once per record.
///
/// All or nothing: if any id is out of range or already deleted (or
/// repeated), nothing is deleted and `MemoryError::NotFound` is returned.
/// Unlike `delete`, this never auto-compacts, so the records stay in the
/// store as tombstones (their indices unchanged) until [`AgentMemory::compact`]
/// is called — importers use it to carry over records that were already
/// deleted in the source.
pub fn delete_batch(&mut self, ids: &[usize]) -> Result<()> {
let mut seen = std::collections::HashSet::with_capacity(ids.len());
for &id in ids {
if self.cache.tombstones.get(id).copied() != Some(0) || !seen.insert(id) {
return Err(MemoryError::NotFound(format!(
"entry {id} not found or already deleted"
)));
}
}
if ids.is_empty() {
return Ok(());
}
for &id in ids {
self.cache.mark_deleted(id);
self.hnsw_on_delete(id);
self.bm25_on_delete(id);
}
self.flush()
}
pub fn tick_session(&mut self) -> Result<()> {
let d = self.config.decay_factor;
for w in self.cache.activation_weights.iter_mut() {
@@ -1599,6 +1640,79 @@ mod tests {
}
}
#[test]
fn delete_batch_tombstones_without_compacting() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.h5");
let mut mem = HDF5Memory::create(make_config(&dir)).unwrap();
mem.save_batch(
(0..4)
.map(|i| make_entry(&format!("record {i}"), &[i as f32, 1.0, 0.0, 0.0]))
.collect(),
)
.unwrap();
// 3 of 4 is far past compact_threshold (0.3): delete() would compact.
mem.delete_batch(&[0, 1, 3]).unwrap();
assert_eq!(mem.count(), 4);
assert_eq!(mem.count_active(), 1);
drop(mem);
let mut mem = HDF5Memory::open(&path).unwrap();
assert_eq!(mem.cache.tombstones, vec![1, 1, 0, 1]);
let hits = mem.hybrid_search(&[0.0, 1.0, 0.0, 0.0], "record", 0.5, 0.5, 10);
assert!(
hits.iter().all(|r| r.index == 2),
"tombstoned record returned"
);
}
#[test]
fn delete_batch_is_all_or_nothing() {
let dir = TempDir::new().unwrap();
let mut mem = HDF5Memory::create(make_config(&dir)).unwrap();
mem.save_batch(vec![
make_entry("a", &[1.0, 0.0, 0.0, 0.0]),
make_entry("b", &[0.0, 1.0, 0.0, 0.0]),
])
.unwrap();
for bad in [&[0, 5][..], &[1, 1][..]] {
assert!(matches!(
mem.delete_batch(bad),
Err(MemoryError::NotFound(_))
));
assert_eq!(mem.count_active(), 2, "{bad:?} deleted something");
}
mem.delete_batch(&[]).unwrap();
assert_eq!(mem.count_active(), 2);
}
#[test]
fn sessions_mut_add_at_keeps_timestamp_across_reopen() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.h5");
let mut mem = HDF5Memory::create(make_config(&dir)).unwrap();
mem.sessions_mut()
.add_at("s-old", 2, 7, "discord", "old summary", 1.7e15);
mem.flush_wal().unwrap();
drop(mem);
let mem = HDF5Memory::open_read_only(&path).unwrap();
let s = mem.sessions();
assert_eq!(s.len(), 1);
let e = &s.entries[0];
assert_eq!(
(
e.id.as_str(),
e.start_idx,
e.end_idx,
e.channel.as_str(),
e.ts
),
("s-old", 2, 7, "discord", 1.7e15)
);
assert_eq!(s.summaries[0], "old summary");
}
#[test]
fn create_new_file() {
let dir = TempDir::new().unwrap();