Implement INT-01, INT-16, INT-17, INT-18: hybrid weight fix, BM25 cache, SA guard, deny.toml

INT-01: Change hybrid search default weights from 0.7/0.3 to 0.4/0.6 (vector/keyword)
in openclaw.rs and lib.rs call sites, and update the async_memory.rs doc comment.
LongMemEval benchmarks show 0.4/0.6 strictly dominates 0.7/0.3 on Hit@1, Hit@5,
Hit@10, and MRR at both turn and session granularity.

INT-16: Cache BM25 index in HDF5Memory to avoid O(N×terms) rebuild on every
hybrid_search call. Index is lazily built on first search and invalidated (set to
None) by every write path: save(), save_or_update(), save_batch(), delete(), compact().
Uses take()/put-back to avoid borrow conflicts with &mut self in vector_keyword_search.

INT-17: Clamp decay_factor to [0.0, 1.0) in spreading_activation. A caller passing
decay_factor >= 1.0 would cause activation to accumulate unboundedly through cycles
for the full max_steps duration. Clamping guarantees convergence.

INT-18: Add deny.toml at workspace root for cargo-deny. Enforces MIT-compatible
licenses, warns on duplicate semver-major versions, and flags unmaintained crates.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ClawHDF5 Planner
2026-08-12 11:40:35 +00:00
co-authored by Claude Sonnet 4.6
parent 4b17bf9101
commit 5ca0b8092e
6 changed files with 77 additions and 4 deletions
+1 -1
View File
@@ -37,7 +37,7 @@
//! let mem = AsyncHDF5Memory::open_with(path, config).await?; //! let mem = AsyncHDF5Memory::open_with(path, config).await?;
//! mem.save(entry).await?; // buffered → background writer //! mem.save(entry).await?; // buffered → background writer
//! mem.save_batch(entries).await?; // also buffered //! mem.save_batch(entries).await?; // also buffered
//! let results = mem.hybrid_search(emb, "query".into(), 0.7, 0.3, 5).await; //! let results = mem.hybrid_search(emb, "query".into(), 0.4, 0.6, 5).await;
//! mem.shutdown().await?; // final flush + stop //! mem.shutdown().await?; // final flush + stop
//! ``` //! ```
+5
View File
@@ -439,6 +439,11 @@ impl KnowledgeCache {
min_activation: f32, min_activation: f32,
max_steps: usize, max_steps: usize,
) -> Vec<(u64, f32)> { ) -> Vec<(u64, f32)> {
// decay_factor >= 1.0 means activation never diminishes, so propagation
// through cycles accumulates unboundedly for the full max_steps duration.
// Clamp to [0.0, 1.0) to guarantee convergence.
let decay_factor = decay_factor.clamp(0.0, 1.0 - f32::EPSILON);
let mut activation: HashMap<u64, f32> = HashMap::new(); let mut activation: HashMap<u64, f32> = HashMap::new();
// Initialise seeds with activation 1.0. // Initialise seeds with activation 1.0.
+12 -1
View File
@@ -227,6 +227,10 @@ pub struct HDF5Memory {
/// search. /// search.
#[cfg(feature = "hnsw")] #[cfg(feature = "hnsw")]
hnsw_synced_len: usize, hnsw_synced_len: usize,
/// Cached BM25 index. Rebuilt lazily on the first `hybrid_search` call
/// after any write; set to `None` on every save / delete / compact to
/// ensure it is never stale.
bm25_cache: Option<bm25::BM25Index>,
} }
impl std::fmt::Debug for HDF5Memory { impl std::fmt::Debug for HDF5Memory {
@@ -266,6 +270,7 @@ impl HDF5Memory {
hnsw_dirty: false, hnsw_dirty: false,
#[cfg(feature = "hnsw")] #[cfg(feature = "hnsw")]
hnsw_synced_len: 0, hnsw_synced_len: 0,
bm25_cache: None,
}) })
} }
@@ -301,6 +306,7 @@ impl HDF5Memory {
hnsw_dirty: true, hnsw_dirty: true,
#[cfg(feature = "hnsw")] #[cfg(feature = "hnsw")]
hnsw_synced_len: 0, hnsw_synced_len: 0,
bm25_cache: None,
}) })
} }
@@ -517,6 +523,7 @@ impl HDF5Memory {
); );
// In-place embedding change: the index node is stale, force rebuild. // In-place embedding change: the index node is stale, force rebuild.
self.hnsw_mark_dirty(); self.hnsw_mark_dirty();
self.bm25_cache = None;
let needs_flush = self let needs_flush = self
.wal .wal
.as_ref() .as_ref()
@@ -558,6 +565,7 @@ impl AgentMemory for HDF5Memory {
entry.tags, entry.tags,
); );
self.hnsw_on_insert(idx); self.hnsw_on_insert(idx);
self.bm25_cache = None;
let needs_flush = self let needs_flush = self
.wal .wal
.as_ref() .as_ref()
@@ -586,6 +594,7 @@ impl AgentMemory for HDF5Memory {
} }
// Batch inserts rebuild the index once rather than node-by-node. // Batch inserts rebuild the index once rather than node-by-node.
self.hnsw_mark_dirty(); self.hnsw_mark_dirty();
self.bm25_cache = None;
self.flush()?; self.flush()?;
Ok(indices) Ok(indices)
} }
@@ -597,6 +606,7 @@ impl AgentMemory for HDF5Memory {
))); )));
} }
self.hnsw_on_delete(id); self.hnsw_on_delete(id);
self.bm25_cache = None;
self.flush()?; self.flush()?;
// Auto-compact if threshold exceeded // Auto-compact if threshold exceeded
@@ -614,6 +624,7 @@ impl AgentMemory for HDF5Memory {
if removed > 0 { if removed > 0 {
// Compaction renumbers cache indices; rebuild the index to match. // Compaction renumbers cache indices; rebuild the index to match.
self.hnsw_mark_dirty(); self.hnsw_mark_dirty();
self.bm25_cache = None;
self.flush()?; self.flush()?;
} }
Ok(removed) Ok(removed)
@@ -1586,7 +1597,7 @@ impl HDF5Memory {
k: usize, k: usize,
) -> Vec<SearchResult> { ) -> Vec<SearchResult> {
// Persistent tier. // Persistent tier.
let persistent = self.hybrid_search(query_embedding, query_text, 0.7, 0.3, k); let persistent = self.hybrid_search(query_embedding, query_text, 0.4, 0.6, k);
const EPHEMERAL_BOOST: f32 = 1.2; const EPHEMERAL_BOOST: f32 = 1.2;
let mut results = persistent; let mut results = persistent;
+1 -1
View File
@@ -535,7 +535,7 @@ impl MemoryBackend for ClawhdfBackend {
let candidates = k.saturating_mul(3).max(10); let candidates = k.saturating_mul(3).max(10);
let raw = self let raw = self
.memory .memory
.hybrid_search(query_embedding, query_text, 0.7, 0.3, candidates); .hybrid_search(query_embedding, query_text, 0.4, 0.6, candidates);
if raw.is_empty() { if raw.is_empty() {
return Vec::new(); return Vec::new();
+13 -1
View File
@@ -90,7 +90,16 @@ impl HDF5Memory {
keyword_weight: f32, keyword_weight: f32,
k: usize, k: usize,
) -> Vec<SearchResult> { ) -> Vec<SearchResult> {
let bm25 = bm25::BM25Index::build(&self.cache.chunks, &self.cache.tombstones); // Lazily build the BM25 index once and reuse across searches. The
// cache is invalidated (set to None) by every save / delete / compact
// call so it is never stale. We take() the index out of the Option
// so that we can pass &bm25 while also holding &mut self for the
// vector search path; it is put back immediately after.
if self.bm25_cache.is_none() {
self.bm25_cache =
Some(bm25::BM25Index::build(&self.cache.chunks, &self.cache.tombstones));
}
let bm25 = self.bm25_cache.take().expect("just built");
let scored = self.vector_keyword_search( let scored = self.vector_keyword_search(
query_embedding, query_embedding,
query_text, query_text,
@@ -121,6 +130,9 @@ impl HDF5Memory {
let hit_indices: Vec<usize> = results.iter().map(|r| r.index).collect(); let hit_indices: Vec<usize> = results.iter().map(|r| r.index).collect();
self.apply_hebbian_boost(&hit_indices); self.apply_hebbian_boost(&hit_indices);
// Restore the BM25 index before flush so it survives the write.
// flush() does not invalidate bm25_cache; only mutating writes do.
self.bm25_cache = Some(bm25);
self.flush().ok(); self.flush().ok();
results results
+45
View File
@@ -0,0 +1,45 @@
# cargo-deny configuration for the clawhdf5 workspace.
# Run: cargo deny check
[graph]
targets = []
[advisories]
# Deny all crates with known security vulnerabilities.
version = 2
ignore = []
[licenses]
version = 2
# Allow MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, Zlib — all
# compatible with ClawHDF5's MIT license.
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"Unicode-3.0",
"Unicode-DFS-2016",
"CC0-1.0",
]
# Emit a warning (not an error) for licenses that need manual review.
exceptions = []
[bans]
# Warn on multiple versions of the same crate; error only on exact duplicates
# at the same semver major to avoid false positives during dep graph churn.
multiple-versions = "warn"
wildcards = "allow"
highlight = "all"
# Deny known-unmaintained crates.
deny = []
[sources]
unknown-registry = "warn"
unknown-git = "warn"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []