ci: lint all targets, run interop suites for real, compile benches
- clippy --all-targets plus a clawhdf5-format feature matrix (parallel, lz4, zstd, pcodec, fast-checksum); fix the accumulated lint backlog in test, bench and feature-gated code (no behaviour changes). - Install python3 + h5py/numpy/netCDF4/xarray in the CI container and set CLAWHDF5_REQUIRE_INTEROP=1, which makes a missing interop dependency a test failure. Every h5py/netCDF4 interop test used to skip silently in CI. Run the #[ignore]d writer_h5py_tests suite explicitly. - cargo bench --no-run so benches can't rot; fix bench.rs and memory_bench.rs, which no longer compiled against the current strategy/consolidation APIs. - Optional fuzz smoke run via CLAWHDF5_FUZZ_SECONDS. - CHANGELOG and docs/known-issues.md updated. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
706189c3ef
commit
bbe1baa208
@@ -483,7 +483,7 @@ fn rayon_benches(c: &mut Criterion) {
|
||||
use rayon::prelude::*;
|
||||
let query_norm = vector_search::compute_norm(&query);
|
||||
let num_cores = rayon::current_num_threads().max(1);
|
||||
let chunk_size = (n + num_cores - 1) / num_cores;
|
||||
let chunk_size = n.div_ceil(num_cores);
|
||||
let mut results: Vec<(usize, f32)> = vectors
|
||||
.par_chunks(chunk_size)
|
||||
.enumerate()
|
||||
@@ -537,7 +537,7 @@ fn rayon_benches(c: &mut Criterion) {
|
||||
use rayon::prelude::*;
|
||||
let query_norm = vector_search::compute_norm(&query);
|
||||
let num_cores = rayon::current_num_threads().max(1);
|
||||
let chunk_size = (n + num_cores - 1) / num_cores;
|
||||
let chunk_size = n.div_ceil(num_cores);
|
||||
let mut results: Vec<(usize, f32)> = vectors
|
||||
.par_chunks(chunk_size)
|
||||
.enumerate()
|
||||
@@ -766,12 +766,22 @@ fn adaptive_benches(c: &mut Criterion) {
|
||||
.map(|v| vector_search::compute_norm(v))
|
||||
.collect();
|
||||
let tombstones = vec![0u8; n];
|
||||
let flat: Vec<f32> = vectors.iter().flatten().copied().collect();
|
||||
|
||||
c.bench_function("adaptive_search_10k", |b| {
|
||||
let hw = HardwareCapabilities::detect();
|
||||
let strat = strategy::auto_select_strategy(n, &hw);
|
||||
b.iter(|| {
|
||||
strategy::search_with_metrics(&query, &vectors, &norms, &tombstones, 10, strat, None)
|
||||
strategy::search_with_metrics(
|
||||
&query,
|
||||
&vectors,
|
||||
&flat,
|
||||
&norms,
|
||||
&tombstones,
|
||||
10,
|
||||
strat,
|
||||
None,
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -781,6 +791,7 @@ fn adaptive_benches(c: &mut Criterion) {
|
||||
strategy::search_with_metrics(
|
||||
&query,
|
||||
&vectors,
|
||||
&flat,
|
||||
&norms,
|
||||
&tombstones,
|
||||
10,
|
||||
@@ -795,6 +806,7 @@ fn adaptive_benches(c: &mut Criterion) {
|
||||
strategy::search_with_metrics(
|
||||
&query,
|
||||
&vectors,
|
||||
&flat,
|
||||
&norms,
|
||||
&tombstones,
|
||||
10,
|
||||
@@ -809,6 +821,7 @@ fn adaptive_benches(c: &mut Criterion) {
|
||||
strategy::search_with_metrics(
|
||||
&query,
|
||||
&vectors,
|
||||
&flat,
|
||||
&norms,
|
||||
&tombstones,
|
||||
10,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use clawhdf5_agent::bm25::BM25Index;
|
||||
use clawhdf5_agent::consolidation::{
|
||||
ConsolidationConfig, ConsolidationEngine, ImportanceScorer, ImportanceWeights, MemorySource,
|
||||
UntrustedSource,
|
||||
};
|
||||
use clawhdf5_agent::hybrid::{hybrid_search, rrf_hybrid_search};
|
||||
use clawhdf5_agent::knowledge::KnowledgeCache;
|
||||
@@ -285,7 +286,12 @@ fn consolidation_benches(c: &mut Criterion) {
|
||||
for i in 0..n {
|
||||
let embedding = make_vec(&mut rng, DIM);
|
||||
let chunk = format!("memory record {i} with some content");
|
||||
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
|
||||
engine.add_memory(
|
||||
chunk,
|
||||
embedding,
|
||||
UntrustedSource::User,
|
||||
now + i as f64,
|
||||
);
|
||||
}
|
||||
engine
|
||||
},
|
||||
@@ -307,9 +313,10 @@ fn consolidation_benches(c: &mut Criterion) {
|
||||
for i in 0..50usize {
|
||||
let embedding = make_vec(&mut rng, DIM);
|
||||
let chunk = format!("existing record {i}");
|
||||
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
|
||||
engine.add_memory(chunk, embedding, UntrustedSource::User, now + i as f64);
|
||||
}
|
||||
let records = engine.records().to_vec();
|
||||
let record_refs: Vec<&_> = records.iter().collect();
|
||||
let weights = ImportanceWeights::default();
|
||||
let query_embedding = make_vec(&mut rng, DIM);
|
||||
let sample_text =
|
||||
@@ -317,7 +324,7 @@ fn consolidation_benches(c: &mut Criterion) {
|
||||
|
||||
group.bench_function("bench_importance_scoring", |b| {
|
||||
b.iter(|| {
|
||||
let surprise = ImportanceScorer::score_surprise(&query_embedding, &records);
|
||||
let surprise = ImportanceScorer::score_surprise(&query_embedding, &record_refs);
|
||||
let correction = ImportanceScorer::score_correction(&MemorySource::Correction);
|
||||
let length = ImportanceScorer::score_length(sample_text);
|
||||
ImportanceScorer::score_combined(surprise, correction, length, &weights)
|
||||
@@ -354,7 +361,7 @@ fn temporal_benches(c: &mut Criterion) {
|
||||
// Insert benchmark: measure time to insert 10k timestamps one by one
|
||||
group.bench_function("bench_temporal_insert_10k", |b| {
|
||||
b.iter_batched(
|
||||
|| TemporalIndex::new(),
|
||||
TemporalIndex::new,
|
||||
|mut idx| {
|
||||
for i in 0..N {
|
||||
// Shuffle insertion order slightly using a simple offset pattern
|
||||
@@ -442,7 +449,8 @@ fn large_consolidation_benches(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("consolidation_large");
|
||||
group.sample_size(10);
|
||||
|
||||
for (label, n) in [("10k", 10_000usize)] {
|
||||
{
|
||||
let (label, n) = ("10k", 10_000usize);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("bench_consolidation_cycle", label),
|
||||
&n,
|
||||
@@ -459,7 +467,12 @@ fn large_consolidation_benches(c: &mut Criterion) {
|
||||
for i in 0..n {
|
||||
let embedding = make_vec(&mut rng, DIM);
|
||||
let chunk = format!("memory record {i} with content");
|
||||
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
|
||||
engine.add_memory(
|
||||
chunk,
|
||||
embedding,
|
||||
UntrustedSource::User,
|
||||
now + i as f64,
|
||||
);
|
||||
}
|
||||
engine
|
||||
},
|
||||
|
||||
@@ -563,7 +563,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_importance_scorer_surprise_identical() {
|
||||
let emb = unit_vec(4, 0);
|
||||
let existing = vec![MemoryRecord {
|
||||
let existing = [MemoryRecord {
|
||||
id: 0,
|
||||
chunk: "existing".to_string(),
|
||||
embedding: emb.clone(),
|
||||
@@ -603,23 +603,20 @@ mod tests {
|
||||
fn test_importance_scorer_length() {
|
||||
assert!((ImportanceScorer::score_length("")).abs() < f32::EPSILON);
|
||||
// 50 words → 0.5
|
||||
let fifty_words = std::iter::repeat("word")
|
||||
.take(50)
|
||||
let fifty_words = std::iter::repeat_n("word", 50)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let s50 = ImportanceScorer::score_length(&fifty_words);
|
||||
assert!((s50 - 0.5).abs() < 1e-5, "expected 0.5, got {s50}");
|
||||
|
||||
// 100 words → 1.0
|
||||
let hundred_words = std::iter::repeat("word")
|
||||
.take(100)
|
||||
let hundred_words = std::iter::repeat_n("word", 100)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
assert_eq!(ImportanceScorer::score_length(&hundred_words), 1.0);
|
||||
|
||||
// 200 words → still 1.0 (clamped)
|
||||
let two_hundred = std::iter::repeat("word")
|
||||
.take(200)
|
||||
let two_hundred = std::iter::repeat_n("word", 200)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
assert_eq!(ImportanceScorer::score_length(&two_hundred), 1.0);
|
||||
@@ -693,9 +690,11 @@ mod tests {
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_consolidate_eviction_working() {
|
||||
let mut cfg = ConsolidationConfig::default();
|
||||
cfg.working_capacity = 3;
|
||||
cfg.working_to_episodic_threshold = 2.0; // never promote in this test
|
||||
let cfg = ConsolidationConfig {
|
||||
working_capacity: 3,
|
||||
working_to_episodic_threshold: 2.0, // never promote in this test
|
||||
..Default::default()
|
||||
};
|
||||
let mut engine = ConsolidationEngine::new(cfg);
|
||||
|
||||
// Add 5 records; all have very low importance so none get promoted.
|
||||
@@ -853,9 +852,11 @@ mod tests {
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_consolidate_episodic_eviction() {
|
||||
let mut cfg = ConsolidationConfig::default();
|
||||
cfg.episodic_capacity = 3;
|
||||
cfg.working_to_episodic_threshold = 2.0; // never auto-promote from Working
|
||||
let cfg = ConsolidationConfig {
|
||||
episodic_capacity: 3,
|
||||
working_to_episodic_threshold: 2.0, // never auto-promote from Working
|
||||
..Default::default()
|
||||
};
|
||||
let mut engine = ConsolidationEngine::new(cfg);
|
||||
|
||||
// Seed 5 records directly in Episodic.
|
||||
|
||||
@@ -777,8 +777,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_tech_disabled() {
|
||||
let mut config = ExtractorConfig::default();
|
||||
config.extract_technology = false;
|
||||
let config = ExtractorConfig {
|
||||
extract_technology: false,
|
||||
..Default::default()
|
||||
};
|
||||
let e = EntityExtractor::new(config);
|
||||
let entities = e.extract("We use Rust and Docker.");
|
||||
assert!(
|
||||
@@ -847,8 +849,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_date_disabled() {
|
||||
let mut config = ExtractorConfig::default();
|
||||
config.extract_dates = false;
|
||||
let config = ExtractorConfig {
|
||||
extract_dates: false,
|
||||
..Default::default()
|
||||
};
|
||||
let e = EntityExtractor::new(config);
|
||||
let entities = e.extract("Released on 2024-03-19.");
|
||||
assert!(
|
||||
@@ -981,8 +985,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_confidence_filter() {
|
||||
let mut config = ExtractorConfig::default();
|
||||
config.min_confidence = 0.95;
|
||||
let config = ExtractorConfig {
|
||||
min_confidence: 0.95,
|
||||
..Default::default()
|
||||
};
|
||||
let e = EntityExtractor::new(config);
|
||||
// Only dates (0.95) and techs (0.9) should survive; 0.9 < 0.95 filters techs.
|
||||
let entities = e.extract("We use Rust since 2024-01-01.");
|
||||
@@ -1002,7 +1008,7 @@ mod tests {
|
||||
fn test_batch_dedup() {
|
||||
let e = default_extractor();
|
||||
let texts = ["We use Rust.", "Rust is fast.", "Also Rust for safety."];
|
||||
let entities = e.extract_batch(&texts.iter().map(|s| *s).collect::<Vec<_>>());
|
||||
let entities = e.extract_batch(&texts);
|
||||
let rust_count = entities.iter().filter(|x| x.text == "Rust").count();
|
||||
assert_eq!(rust_count, 1, "Rust should appear exactly once after dedup");
|
||||
}
|
||||
@@ -1011,7 +1017,7 @@ mod tests {
|
||||
fn test_batch_multiple_types() {
|
||||
let e = default_extractor();
|
||||
let texts = ["Deploy with Docker.", "We merged last week."];
|
||||
let entities = e.extract_batch(&texts.iter().map(|s| *s).collect::<Vec<_>>());
|
||||
let entities = e.extract_batch(&texts);
|
||||
assert!(
|
||||
entities
|
||||
.iter()
|
||||
|
||||
+193
-193
@@ -839,6 +839,199 @@ fn is_leap(y: i64) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||
}
|
||||
|
||||
impl HDF5Memory {
|
||||
pub fn set_strategy(&mut self, s: Box<dyn MemoryStrategy>) {
|
||||
self.strategy = Some(s);
|
||||
}
|
||||
pub fn record(&mut self, exchange: Exchange) -> Result<StrategyOutput> {
|
||||
let strat = self.strategy.as_ref().ok_or_else(|| {
|
||||
MemoryError::Schema(
|
||||
"strategy not initialized: call set_strategy() before record()".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let view = memory_strategy::CacheStoreView::new(&self.cache, &self.knowledge);
|
||||
let output = strat.evaluate(&exchange, &view);
|
||||
for e in &output.entries {
|
||||
self.cache.push(
|
||||
e.chunk.clone(),
|
||||
e.embedding.clone(),
|
||||
e.source_channel.clone(),
|
||||
e.timestamp,
|
||||
e.session_id.clone(),
|
||||
e.tags.clone(),
|
||||
);
|
||||
}
|
||||
for eu in &output.entity_updates {
|
||||
let id = self.knowledge.add_entity(&eu.name, &eu.entity_type, -1);
|
||||
for a in &eu.aliases {
|
||||
self.knowledge.add_alias(a, id as i64);
|
||||
}
|
||||
}
|
||||
if !output.entries.is_empty() || !output.entity_updates.is_empty() {
|
||||
self.flush()?;
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
impl HDF5Memory {
|
||||
pub fn tick_session(&mut self) -> Result<()> {
|
||||
let d = self.config.decay_factor;
|
||||
for w in self.cache.activation_weights.iter_mut() {
|
||||
*w *= d;
|
||||
}
|
||||
self.flush()?;
|
||||
if let Some(ref mut w) = self.wal {
|
||||
w.truncate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of pending WAL entries (0 if WAL disabled).
|
||||
pub fn wal_pending_count(&self) -> usize {
|
||||
self.wal.as_ref().map_or(0, |w| w.pending_count() as usize)
|
||||
}
|
||||
|
||||
/// Explicit WAL merge: flush .h5, truncate WAL.
|
||||
pub fn flush_wal(&mut self) -> Result<()> {
|
||||
self.flush()?;
|
||||
if let Some(ref mut w) = self.wal {
|
||||
w.truncate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Ephemeral tier integration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl HDF5Memory {
|
||||
/// Enable the ephemeral working memory tier with the given configuration.
|
||||
pub fn enable_ephemeral(&mut self, config: EphemeralConfig) {
|
||||
self.ephemeral = Some(EphemeralStore::new(config));
|
||||
}
|
||||
|
||||
/// Return a shared reference to the ephemeral store, if enabled.
|
||||
pub fn ephemeral(&self) -> Option<&EphemeralStore> {
|
||||
self.ephemeral.as_ref()
|
||||
}
|
||||
|
||||
/// Return a mutable reference to the ephemeral store, if enabled.
|
||||
pub fn ephemeral_mut(&mut self) -> Option<&mut EphemeralStore> {
|
||||
self.ephemeral.as_mut()
|
||||
}
|
||||
|
||||
/// Promote frequently-accessed ephemeral entries into the persistent cache.
|
||||
///
|
||||
/// Every entry whose `access_count >= min_access_count` is removed from the
|
||||
/// ephemeral store and written to the HDF5 cache, then the file is flushed.
|
||||
/// Returns the number of entries promoted.
|
||||
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize> {
|
||||
let candidates = match &self.ephemeral {
|
||||
None => return Ok(0),
|
||||
Some(s) => s.promotion_candidates(min_access_count),
|
||||
};
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let dim = self.config.embedding_dim;
|
||||
let mut promoted = 0;
|
||||
|
||||
for key in candidates {
|
||||
let entry = match self
|
||||
.ephemeral
|
||||
.as_mut()
|
||||
.and_then(|s| s.take_for_promotion(&key))
|
||||
{
|
||||
Some(e) => e,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let chunk = entry
|
||||
.text
|
||||
.clone()
|
||||
.unwrap_or_else(|| String::from_utf8_lossy(&entry.value).into_owned());
|
||||
let embedding = entry.embedding.clone().unwrap_or_else(|| vec![0.0f32; dim]);
|
||||
|
||||
self.cache.push(
|
||||
chunk,
|
||||
embedding,
|
||||
format!("ephemeral::{key}"),
|
||||
entry.created_at,
|
||||
String::new(),
|
||||
entry.tags.join(","),
|
||||
);
|
||||
promoted += 1;
|
||||
}
|
||||
|
||||
if promoted > 0 {
|
||||
self.flush()?;
|
||||
}
|
||||
Ok(promoted)
|
||||
}
|
||||
|
||||
/// Search both the persistent HDF5 tier and the ephemeral tier, returning
|
||||
/// the top `k` results sorted by score descending.
|
||||
///
|
||||
/// Ephemeral results are boosted by a factor of 1.2 to surface recent
|
||||
/// in-context information above older persisted data.
|
||||
pub fn unified_search(
|
||||
&mut self,
|
||||
query_embedding: &[f32],
|
||||
query_text: &str,
|
||||
k: usize,
|
||||
) -> Vec<SearchResult> {
|
||||
// Persistent tier.
|
||||
let persistent = self.hybrid_search(query_embedding, query_text, 0.7, 0.3, k);
|
||||
const EPHEMERAL_BOOST: f32 = 1.2;
|
||||
let mut results = persistent;
|
||||
|
||||
if self.ephemeral.is_none() {
|
||||
return results;
|
||||
}
|
||||
|
||||
let eph = self.ephemeral.as_mut().unwrap();
|
||||
|
||||
// Collect (key, score) pairs from ephemeral — borrow ends before we
|
||||
// access entries again below.
|
||||
let eph_hits: Vec<(String, f32)> = if !query_embedding.is_empty() {
|
||||
eph.search_embedding(query_embedding, k)
|
||||
} else if !query_text.is_empty() {
|
||||
eph.search_text(query_text, k)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for (key, score) in &eph_hits {
|
||||
if let Some(entry) = eph.get_entry(key) {
|
||||
let chunk = entry
|
||||
.text
|
||||
.clone()
|
||||
.unwrap_or_else(|| String::from_utf8_lossy(&entry.value).into_owned());
|
||||
results.push(SearchResult {
|
||||
score: score * EPHEMERAL_BOOST,
|
||||
chunk,
|
||||
index: usize::MAX,
|
||||
timestamp: entry.created_at,
|
||||
source_channel: format!("ephemeral::{key}"),
|
||||
activation: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
results.truncate(k);
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1676,196 +1869,3 @@ mod tests {
|
||||
assert!((mem.cache.tombstone_fraction() - 0.50).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
impl HDF5Memory {
|
||||
pub fn set_strategy(&mut self, s: Box<dyn MemoryStrategy>) {
|
||||
self.strategy = Some(s);
|
||||
}
|
||||
pub fn record(&mut self, exchange: Exchange) -> Result<StrategyOutput> {
|
||||
let strat = self.strategy.as_ref().ok_or_else(|| {
|
||||
MemoryError::Schema(
|
||||
"strategy not initialized: call set_strategy() before record()".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let view = memory_strategy::CacheStoreView::new(&self.cache, &self.knowledge);
|
||||
let output = strat.evaluate(&exchange, &view);
|
||||
for e in &output.entries {
|
||||
self.cache.push(
|
||||
e.chunk.clone(),
|
||||
e.embedding.clone(),
|
||||
e.source_channel.clone(),
|
||||
e.timestamp,
|
||||
e.session_id.clone(),
|
||||
e.tags.clone(),
|
||||
);
|
||||
}
|
||||
for eu in &output.entity_updates {
|
||||
let id = self.knowledge.add_entity(&eu.name, &eu.entity_type, -1);
|
||||
for a in &eu.aliases {
|
||||
self.knowledge.add_alias(a, id as i64);
|
||||
}
|
||||
}
|
||||
if !output.entries.is_empty() || !output.entity_updates.is_empty() {
|
||||
self.flush()?;
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
impl HDF5Memory {
|
||||
pub fn tick_session(&mut self) -> Result<()> {
|
||||
let d = self.config.decay_factor;
|
||||
for w in self.cache.activation_weights.iter_mut() {
|
||||
*w *= d;
|
||||
}
|
||||
self.flush()?;
|
||||
if let Some(ref mut w) = self.wal {
|
||||
w.truncate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of pending WAL entries (0 if WAL disabled).
|
||||
pub fn wal_pending_count(&self) -> usize {
|
||||
self.wal.as_ref().map_or(0, |w| w.pending_count() as usize)
|
||||
}
|
||||
|
||||
/// Explicit WAL merge: flush .h5, truncate WAL.
|
||||
pub fn flush_wal(&mut self) -> Result<()> {
|
||||
self.flush()?;
|
||||
if let Some(ref mut w) = self.wal {
|
||||
w.truncate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Ephemeral tier integration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl HDF5Memory {
|
||||
/// Enable the ephemeral working memory tier with the given configuration.
|
||||
pub fn enable_ephemeral(&mut self, config: EphemeralConfig) {
|
||||
self.ephemeral = Some(EphemeralStore::new(config));
|
||||
}
|
||||
|
||||
/// Return a shared reference to the ephemeral store, if enabled.
|
||||
pub fn ephemeral(&self) -> Option<&EphemeralStore> {
|
||||
self.ephemeral.as_ref()
|
||||
}
|
||||
|
||||
/// Return a mutable reference to the ephemeral store, if enabled.
|
||||
pub fn ephemeral_mut(&mut self) -> Option<&mut EphemeralStore> {
|
||||
self.ephemeral.as_mut()
|
||||
}
|
||||
|
||||
/// Promote frequently-accessed ephemeral entries into the persistent cache.
|
||||
///
|
||||
/// Every entry whose `access_count >= min_access_count` is removed from the
|
||||
/// ephemeral store and written to the HDF5 cache, then the file is flushed.
|
||||
/// Returns the number of entries promoted.
|
||||
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize> {
|
||||
let candidates = match &self.ephemeral {
|
||||
None => return Ok(0),
|
||||
Some(s) => s.promotion_candidates(min_access_count),
|
||||
};
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let dim = self.config.embedding_dim;
|
||||
let mut promoted = 0;
|
||||
|
||||
for key in candidates {
|
||||
let entry = match self
|
||||
.ephemeral
|
||||
.as_mut()
|
||||
.and_then(|s| s.take_for_promotion(&key))
|
||||
{
|
||||
Some(e) => e,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let chunk = entry
|
||||
.text
|
||||
.clone()
|
||||
.unwrap_or_else(|| String::from_utf8_lossy(&entry.value).into_owned());
|
||||
let embedding = entry.embedding.clone().unwrap_or_else(|| vec![0.0f32; dim]);
|
||||
|
||||
self.cache.push(
|
||||
chunk,
|
||||
embedding,
|
||||
format!("ephemeral::{key}"),
|
||||
entry.created_at,
|
||||
String::new(),
|
||||
entry.tags.join(","),
|
||||
);
|
||||
promoted += 1;
|
||||
}
|
||||
|
||||
if promoted > 0 {
|
||||
self.flush()?;
|
||||
}
|
||||
Ok(promoted)
|
||||
}
|
||||
|
||||
/// Search both the persistent HDF5 tier and the ephemeral tier, returning
|
||||
/// the top `k` results sorted by score descending.
|
||||
///
|
||||
/// Ephemeral results are boosted by a factor of 1.2 to surface recent
|
||||
/// in-context information above older persisted data.
|
||||
pub fn unified_search(
|
||||
&mut self,
|
||||
query_embedding: &[f32],
|
||||
query_text: &str,
|
||||
k: usize,
|
||||
) -> Vec<SearchResult> {
|
||||
// Persistent tier.
|
||||
let persistent = self.hybrid_search(query_embedding, query_text, 0.7, 0.3, k);
|
||||
const EPHEMERAL_BOOST: f32 = 1.2;
|
||||
let mut results = persistent;
|
||||
|
||||
if self.ephemeral.is_none() {
|
||||
return results;
|
||||
}
|
||||
|
||||
let eph = self.ephemeral.as_mut().unwrap();
|
||||
|
||||
// Collect (key, score) pairs from ephemeral — borrow ends before we
|
||||
// access entries again below.
|
||||
let eph_hits: Vec<(String, f32)> = if !query_embedding.is_empty() {
|
||||
eph.search_embedding(query_embedding, k)
|
||||
} else if !query_text.is_empty() {
|
||||
eph.search_text(query_text, k)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for (key, score) in &eph_hits {
|
||||
if let Some(entry) = eph.get_entry(key) {
|
||||
let chunk = entry
|
||||
.text
|
||||
.clone()
|
||||
.unwrap_or_else(|| String::from_utf8_lossy(&entry.value).into_owned());
|
||||
results.push(SearchResult {
|
||||
score: score * EPHEMERAL_BOOST,
|
||||
chunk,
|
||||
index: usize::MAX,
|
||||
timestamp: entry.created_at,
|
||||
source_channel: format!("ephemeral::{key}"),
|
||||
activation: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
results.truncate(k);
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +748,69 @@ impl MemoryBackend for ClawhdfBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Ephemeral tier methods on ClawhdfBackend
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl ClawhdfBackend {
|
||||
/// Enable the ephemeral (in-memory only) working memory tier.
|
||||
pub fn enable_ephemeral(&mut self, config: crate::ephemeral::EphemeralConfig) {
|
||||
self.memory.enable_ephemeral(config);
|
||||
}
|
||||
|
||||
/// Store a text value in ephemeral memory.
|
||||
///
|
||||
/// Returns an error string if the ephemeral tier has not been enabled.
|
||||
pub fn ephemeral_set(
|
||||
&mut self,
|
||||
key: &str,
|
||||
value: &str,
|
||||
ttl_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
match self.memory.ephemeral_mut() {
|
||||
Some(s) => {
|
||||
s.set_text(key, value, ttl_secs);
|
||||
Ok(())
|
||||
}
|
||||
None => Err("ephemeral tier not enabled".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a text value from ephemeral memory.
|
||||
///
|
||||
/// Returns `None` if the tier is disabled, the key is absent, or the
|
||||
/// entry has expired.
|
||||
pub fn ephemeral_get(&mut self, key: &str) -> Option<String> {
|
||||
self.memory
|
||||
.ephemeral_mut()?
|
||||
.get_text(key)
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Delete a key from ephemeral memory.
|
||||
///
|
||||
/// Returns `true` if the key existed and was removed.
|
||||
pub fn ephemeral_delete(&mut self, key: &str) -> bool {
|
||||
self.memory.ephemeral_mut().is_some_and(|s| s.delete(key))
|
||||
}
|
||||
|
||||
/// Return a snapshot of ephemeral tier statistics, or `None` if the tier
|
||||
/// is not enabled.
|
||||
pub fn ephemeral_stats(&self) -> Option<crate::ephemeral::EphemeralStats> {
|
||||
self.memory.ephemeral().map(|s| s.stats())
|
||||
}
|
||||
|
||||
/// Promote frequently-accessed ephemeral entries to persistent HDF5 storage.
|
||||
///
|
||||
/// Entries with `access_count >= min_access_count` are moved from the
|
||||
/// ephemeral store into the persistent cache. Returns the count promoted.
|
||||
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize, String> {
|
||||
self.memory
|
||||
.promote_ephemeral(min_access_count)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1333,66 +1396,3 @@ mod tests {
|
||||
assert!(out.starts_with("# Title"));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Ephemeral tier methods on ClawhdfBackend
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl ClawhdfBackend {
|
||||
/// Enable the ephemeral (in-memory only) working memory tier.
|
||||
pub fn enable_ephemeral(&mut self, config: crate::ephemeral::EphemeralConfig) {
|
||||
self.memory.enable_ephemeral(config);
|
||||
}
|
||||
|
||||
/// Store a text value in ephemeral memory.
|
||||
///
|
||||
/// Returns an error string if the ephemeral tier has not been enabled.
|
||||
pub fn ephemeral_set(
|
||||
&mut self,
|
||||
key: &str,
|
||||
value: &str,
|
||||
ttl_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
match self.memory.ephemeral_mut() {
|
||||
Some(s) => {
|
||||
s.set_text(key, value, ttl_secs);
|
||||
Ok(())
|
||||
}
|
||||
None => Err("ephemeral tier not enabled".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a text value from ephemeral memory.
|
||||
///
|
||||
/// Returns `None` if the tier is disabled, the key is absent, or the
|
||||
/// entry has expired.
|
||||
pub fn ephemeral_get(&mut self, key: &str) -> Option<String> {
|
||||
self.memory
|
||||
.ephemeral_mut()?
|
||||
.get_text(key)
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Delete a key from ephemeral memory.
|
||||
///
|
||||
/// Returns `true` if the key existed and was removed.
|
||||
pub fn ephemeral_delete(&mut self, key: &str) -> bool {
|
||||
self.memory.ephemeral_mut().is_some_and(|s| s.delete(key))
|
||||
}
|
||||
|
||||
/// Return a snapshot of ephemeral tier statistics, or `None` if the tier
|
||||
/// is not enabled.
|
||||
pub fn ephemeral_stats(&self) -> Option<crate::ephemeral::EphemeralStats> {
|
||||
self.memory.ephemeral().map(|s| s.stats())
|
||||
}
|
||||
|
||||
/// Promote frequently-accessed ephemeral entries to persistent HDF5 storage.
|
||||
///
|
||||
/// Entries with `access_count >= min_access_count` are moved from the
|
||||
/// ephemeral store into the persistent cache. Returns the count promoted.
|
||||
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize, String> {
|
||||
self.memory
|
||||
.promote_ephemeral(min_access_count)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,7 +786,7 @@ mod tests {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let wal_path = dir.path().join("test.h5.wal");
|
||||
let unicode_chunk = "Hello 世界! 🌍 émojis & ünïcödé";
|
||||
let embedding = vec![0.1, -0.2, 3.14159, f32::MAX, f32::MIN_POSITIVE];
|
||||
let embedding = vec![0.1, -0.2, 3.4567, f32::MAX, f32::MIN_POSITIVE];
|
||||
{
|
||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
||||
let entry = WalEntry {
|
||||
|
||||
@@ -1048,7 +1048,7 @@ fn test_gpu_l2_fallback_works() {
|
||||
let tombstones = vec![0u8; 3];
|
||||
|
||||
let gpu = clawhdf5_agent::gpu_search::GpuSearchBackend::try_init(&vectors, &norms, 2, 1);
|
||||
let results = gpu.search_l2(&vec![0.0, 0.0], &vectors, &tombstones, 3);
|
||||
let results = gpu.search_l2(&[0.0, 0.0], &vectors, &tombstones, 3);
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0].0, 0);
|
||||
@@ -1099,7 +1099,7 @@ fn test_mmap_reader_direct_access() {
|
||||
|
||||
// Open via MmapReader directly
|
||||
let mmap = clawhdf5_io::MmapReader::open(&path).unwrap();
|
||||
assert!(mmap.len() > 0);
|
||||
assert!(!mmap.is_empty());
|
||||
// Verify we can read bytes at specific offsets
|
||||
let bytes = mmap.read_at(0, 8);
|
||||
assert!(bytes.is_some());
|
||||
|
||||
@@ -137,10 +137,10 @@ fn bench_hit_at_1_1014_records() {
|
||||
0.3,
|
||||
1,
|
||||
);
|
||||
if let Some((top_idx, _)) = results.first() {
|
||||
if *top_idx == target_indices[qi] {
|
||||
hits += 1;
|
||||
}
|
||||
if let Some((top_idx, _)) = results.first()
|
||||
&& *top_idx == target_indices[qi]
|
||||
{
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user