Files
clawhdf5/crates/clawhdf5-agent/src/search.rs
T
Omar Sobh 55959b4920
CI / test (push) Failing after 15s
ci: wire up CI, fix no_std build, fix stale package names in scripts
- Add .gitea/workflows/ci.yml running scripts/ci-test.sh (fmt, clippy,
  test, no_std check) on push/PR to main.
- Fix stale rustyhdf5-py/rustyhdf5-format package names in
  ci-test.sh/check-nostd.sh, which had been silently no-op'ing those
  checks (cargo warns but doesn't fail on an unknown --exclude/-p
  target).
- With those checks actually running, fix the real issues they surface:
  - clippy: useless_conversion in chunked_write.rs, byte_char_slices in
    global_heap.rs/object_header.rs.
  - cargo fmt: apply formatting across the workspace (whitespace only).
  - no_std (thumbv7em-none-eabihf) build errors in clawhdf5-format:
    core::sync::atomic::AtomicU64 doesn't exist on that target (no
    native 64-bit atomics) — switch profiling.rs's counters to
    portable-atomic, which falls back to a CAS-based emulation there
    and is a no-op wrapper elsewhere. Add missing alloc imports for
    Box (filters.rs), Vec (filters_szip.rs), and format! (dict_encoding.rs)
    on no_std paths. Replace f64::powi (std/libm-only) with a small
    local exponentiation-by-squaring helper in the scale-offset filter.
2026-08-05 10:50:13 -07:00

162 lines
5.4 KiB
Rust

//! Search and agents_md methods for HDF5Memory.
use std::path::Path;
use crate::bm25;
use crate::hybrid;
use crate::{HDF5Memory, MemoryError, Result, SearchResult};
impl HDF5Memory {
/// Vector + keyword scoring stage of [`HDF5Memory::hybrid_search`].
///
/// Without the `hnsw` feature this is a full linear cosine scan (the exact
/// previous behaviour, also used as the correctness oracle in tests). With
/// `hnsw` enabled and an index available, the vector candidates come from an
/// approximate-nearest-neighbour search over an over-fetched pool, then merge
/// with BM25 via the shared [`hybrid::merge_vector_keyword`].
#[cfg(feature = "hnsw")]
fn vector_keyword_search(
&mut self,
query_embedding: &[f32],
query_text: &str,
bm25: &bm25::BM25Index,
vector_weight: f32,
keyword_weight: f32,
k: usize,
) -> Vec<(usize, f32)> {
self.ensure_hnsw_fresh();
match self.hnsw.as_ref() {
Some(index) if !index.is_empty() && index.dimension() == query_embedding.len() => {
// Over-fetch so the merge sees a useful vector pool; cosine
// distance from the index converts back to similarity (1 - d).
let pool = (k * 8).max(64);
let vec_scores: Vec<(usize, f32)> = index
.search(query_embedding, pool, pool)
.into_iter()
.map(|(id, dist)| (id, 1.0 - dist))
.collect();
let kw_scores = bm25.search(query_text, self.cache.len());
hybrid::merge_vector_keyword(
vec_scores,
kw_scores,
vector_weight,
keyword_weight,
k,
)
}
_ => hybrid::hybrid_search(
query_embedding,
query_text,
&self.cache.embeddings,
&self.cache.chunks,
&self.cache.tombstones,
bm25,
vector_weight,
keyword_weight,
k,
),
}
}
#[cfg(not(feature = "hnsw"))]
fn vector_keyword_search(
&mut self,
query_embedding: &[f32],
query_text: &str,
bm25: &bm25::BM25Index,
vector_weight: f32,
keyword_weight: f32,
k: usize,
) -> Vec<(usize, f32)> {
hybrid::hybrid_search(
query_embedding,
query_text,
&self.cache.embeddings,
&self.cache.chunks,
&self.cache.tombstones,
bm25,
vector_weight,
keyword_weight,
k,
)
}
/// Perform hybrid search combining cosine vector similarity and BM25 keyword search.
pub fn hybrid_search(
&mut self,
query_embedding: &[f32],
query_text: &str,
vector_weight: f32,
keyword_weight: f32,
k: usize,
) -> Vec<SearchResult> {
let bm25 = bm25::BM25Index::build(&self.cache.chunks, &self.cache.tombstones);
let scored = self.vector_keyword_search(
query_embedding,
query_text,
&bm25,
vector_weight,
keyword_weight,
k,
);
let mut results: Vec<SearchResult> = scored
.into_iter()
.map(|(idx, score)| {
let w = self.cache.activation_weights[idx];
SearchResult {
score: score * w.sqrt(),
chunk: self.cache.chunks[idx].clone(),
index: idx,
timestamp: self.cache.timestamps[idx],
source_channel: self.cache.source_channels[idx].clone(),
activation: w,
}
})
.collect();
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let hit_indices: Vec<usize> = results.iter().map(|r| r.index).collect();
self.apply_hebbian_boost(&hit_indices);
self.flush().ok();
results
}
fn apply_hebbian_boost(&mut self, hit_indices: &[usize]) {
for &idx in hit_indices {
self.cache.activation_weights[idx] += self.config.hebbian_boost;
}
}
/// Get the chunk text for a memory entry by index.
pub fn get_chunk(&self, index: usize) -> Option<&str> {
if index < self.cache.chunks.len() && self.cache.tombstones[index] == 0 {
Some(&self.cache.chunks[index])
} else {
None
}
}
/// Generate an AGENTS.md string from current memory state.
pub fn generate_agents_md(&self) -> String {
crate::agents_md::generate(&self.config, &self.cache, &self.sessions, &self.knowledge)
}
/// Write AGENTS.md to disk alongside the .h5 file.
pub fn write_agents_md(&self) -> Result<()> {
let md = self.generate_agents_md();
let md_path = self.config.path.with_extension("agents.md");
std::fs::write(&md_path, md).map_err(MemoryError::Io)
}
/// Read AGENTS.md from disk (if it exists).
pub fn read_agents_md(path: &Path) -> Result<String> {
let md_path = path.with_extension("agents.md");
std::fs::read_to_string(&md_path).map_err(MemoryError::Io)
}
}