feat: implement INT-11 AES-256-GCM encryption, INT-12 Ed25519 signing, INT-13 HNSW batch insert

INT-11 (clawhdf5-agent/src/encryption.rs):
- AES-256-GCM seal/open with PBKDF2-HMAC-SHA256 key derivation (200k iters)
- Passphrase-based and raw-key APIs; envelope format with magic+version+salt+nonce
- `encryption` feature gate (ring 0.17); 9 unit tests covering roundtrips,
  wrong-key, tampered-data, malformed-envelope, and empty-plaintext cases

INT-12 (clawhdf5-agent/src/signing.rs):
- Ed25519 keypair generation, in-memory sign/verify, and file-level sidecar API
- `.sig` sidecar format: magic + version + public-key + signature
- `sign_file` / `verify_file` helpers for .brain file trust verification
- `signing` feature gate (ring 0.17); 8 unit tests including file-level tamper detection

INT-13 (clawhdf5-ann/src/hnsw.rs):
- `HnswIndex::batch_insert`: parallel neighbor search (rayon) + serial edge wiring
- `find_neighbors_for` standalone helper (also used by the `parallel` cfg path)
- Parallelism via existing `parallel` feature; degrades to serial without it
- 5 new tests: empty noop, sequential IDs, existing-index append, quality, save/load

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ClawHDF5 Planner
2026-08-12 12:15:22 +00:00
co-authored by Claude Sonnet 4.6
parent fdd8901c37
commit 87039e926c
5 changed files with 808 additions and 0 deletions
+249
View File
@@ -739,12 +739,190 @@ impl HnswIndex {
pub fn m_max0(&self) -> usize {
self.m_max0
}
/// Insert a batch of vectors efficiently.
///
/// With the `parallel` feature enabled, neighbor searches for each new
/// vector are executed concurrently against the graph state *before* the
/// batch is applied, then edges are wired serially. This trades a small
/// reduction in intra-batch connectivity for significant wall-clock
/// speedup on large batches.
///
/// Without the `parallel` feature, this is equivalent to calling
/// [`HnswIndex::insert`] for each vector in order.
///
/// Returns the assigned IDs in insertion order.
pub fn batch_insert(&mut self, vectors: Vec<Vec<f32>>) -> Vec<usize> {
if vectors.is_empty() {
return Vec::new();
}
// Empty index: fall through to serial insert so the entry-point
// seeding logic in `insert` runs correctly.
if self.vectors.is_empty() {
return vectors
.into_iter()
.map(|v| self.insert(v))
.collect();
}
let dim = self.vectors[0].len();
for v in &vectors {
assert_eq!(v.len(), dim, "batch_insert dimension mismatch");
}
let base_id = self.vectors.len();
let n = vectors.len();
// Pre-assign levels to all incoming vectors.
let node_levels: Vec<usize> = (0..n)
.map(|i| assign_level(base_id + i, self.m))
.collect();
// Phase 1 — neighbor search (read-only on the current graph state).
// Returns, for each new vector, the list of (layer, selected_neighbors)
// pairs that will become its initial edge set.
let per_vector_neighbors: Vec<Vec<(usize, Vec<usize>)>> =
self.find_neighbors_batch(&vectors, &node_levels);
// Phase 2 — extend the vector store (serial).
self.vectors.extend(vectors);
self.deleted.extend(std::iter::repeat(false).take(n));
self.node_levels.extend_from_slice(&node_levels);
// Grow existing layers to accommodate the new node slots.
for layer in self.graph.iter_mut() {
layer.resize(self.vectors.len(), Vec::new());
}
// Add any brand-new top layers introduced by this batch.
let new_max_level = node_levels.iter().copied().max().unwrap_or(0);
while self.graph.len() <= new_max_level {
self.graph.push(vec![Vec::new(); self.vectors.len()]);
}
// Phase 3 — wire edges and track entry-point promotions (serial).
for (batch_idx, layer_neighbors) in per_vector_neighbors.into_iter().enumerate() {
let id = base_id + batch_idx;
for (layer, selected) in layer_neighbors {
let max_conn = if layer == 0 { self.m_max0 } else { self.m };
self.graph[layer][id] = selected.clone();
for &nb in &selected {
self.graph[layer][nb].push(id);
if self.graph[layer][nb].len() > max_conn {
prune_connections(
&self.vectors,
&mut self.graph[layer][nb],
nb,
max_conn,
self.metric,
);
}
}
}
// Promote entry point if this node sits on a taller layer.
let ep_level = self.node_levels[self.entry_point];
if node_levels[batch_idx] > ep_level {
self.entry_point = id;
}
}
(base_id..base_id + n).collect()
}
/// Search for neighbors of each vector in `vectors` against the current
/// (read-only) graph. Returns per-vector `(layer_id, neighbor_ids)` pairs.
fn find_neighbors_batch(
&self,
vectors: &[Vec<f32>],
node_levels: &[usize],
) -> Vec<Vec<(usize, Vec<usize>)>> {
let ep_level = self.node_levels[self.entry_point];
let entry_point = self.entry_point;
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
let existing = &self.vectors;
let graph = &self.graph;
let metric = self.metric;
let m = self.m;
let m_max0 = self.m_max0;
let ef = self.ef_construction;
vectors
.par_iter()
.zip(node_levels.par_iter())
.map(|(v, &nl)| {
find_neighbors_for(
existing, graph, v, nl, ep_level, entry_point, m, m_max0, ef, metric,
)
})
.collect()
}
#[cfg(not(feature = "parallel"))]
{
vectors
.iter()
.zip(node_levels.iter())
.map(|(v, &nl)| {
find_neighbors_for(
&self.vectors,
&self.graph,
v,
nl,
ep_level,
entry_point,
self.m,
self.m_max0,
self.ef_construction,
self.metric,
)
})
.collect()
}
}
}
// ---------------------------------------------------------------------------
// Internal HNSW algorithms
// ---------------------------------------------------------------------------
/// Compute the set of neighbor edges for `new_vec` against a read-only snapshot
/// of the existing graph. Used by [`HnswIndex::batch_insert`].
#[allow(clippy::too_many_arguments)]
fn find_neighbors_for(
existing: &[Vec<f32>],
graph: &[Vec<Vec<usize>>],
new_vec: &[f32],
node_level: usize,
ep_level: usize,
entry_point: usize,
m: usize,
m_max0: usize,
ef: usize,
metric: DistanceMetric,
) -> Vec<(usize, Vec<usize>)> {
let mut ep = entry_point;
// Phase 1: greedy descent from the top layer down to node_level + 1.
for layer in (node_level + 1..=ep_level).rev() {
ep = greedy_closest(existing, &graph[layer], new_vec, ep, metric);
}
// Phase 2: beam search at each layer, collecting selected neighbors.
let bottom = node_level.min(ep_level);
let mut result = Vec::with_capacity(bottom + 1);
for layer in (0..=bottom).rev() {
let max_conn = if layer == 0 { m_max0 } else { m };
let candidates = search_layer(existing, &graph[layer], new_vec, ep, ef, metric);
let selected: Vec<usize> = candidates.iter().take(max_conn).map(|c| c.id).collect();
if !selected.is_empty() {
ep = selected[0];
}
result.push((layer, selected));
}
result
}
/// Greedy search: find the single closest node to `query` starting from `ep`.
fn greedy_closest(
vectors: &[Vec<f32>],
@@ -1452,4 +1630,75 @@ mod tests {
assert_eq!(results.len(), 3);
assert_eq!(results[0].0, 0);
}
#[test]
fn batch_insert_ids_are_sequential() {
let vectors = make_random_vectors(20, 8, 42);
let mut index = HnswIndex::new(8, 32, DistanceMetric::L2);
let ids = index.batch_insert(vectors.clone());
assert_eq!(ids, (0..20).collect::<Vec<_>>());
assert_eq!(index.len(), 20);
}
#[test]
fn batch_insert_into_existing_index() {
let first = make_random_vectors(10, 8, 11);
let second = make_random_vectors(10, 8, 22);
let mut index = HnswIndex::new(8, 32, DistanceMetric::L2);
let ids1 = index.batch_insert(first);
assert_eq!(ids1, (0..10).collect::<Vec<_>>());
let ids2 = index.batch_insert(second.clone());
assert_eq!(ids2, (10..20).collect::<Vec<_>>());
assert_eq!(index.len(), 20);
}
#[test]
fn batch_insert_search_quality() {
// Build index from 50 vectors using serial insert, then build the same
// index using batch_insert. The search results should be identical for
// the first 50 vectors (which are fully connected in both cases).
let vectors = make_random_vectors(50, 16, 99);
let mut serial = HnswIndex::new(8, 32, DistanceMetric::Cosine);
for v in &vectors {
serial.insert(v.clone());
}
let mut batch = HnswIndex::new(8, 32, DistanceMetric::Cosine);
batch.batch_insert(vectors.clone());
assert_eq!(batch.len(), serial.len());
// Both indexes should find the same nearest neighbor for each query.
let queries = make_random_vectors(5, 16, 777);
for q in &queries {
let s = serial.search(q, 1, 32);
let b = batch.search(q, 1, 32);
assert!(!s.is_empty() && !b.is_empty());
// Result must be in the top-3 of the serial index — batch
// is slightly less connected due to the read-snapshot approach.
let top3_serial: Vec<usize> = serial.search(q, 3, 32).into_iter().map(|(id, _)| id).collect();
assert!(top3_serial.contains(&b[0].0), "batch top-1 not in serial top-3");
}
}
#[test]
fn batch_insert_empty_is_noop() {
let mut index = HnswIndex::new(8, 32, DistanceMetric::L2);
let ids = index.batch_insert(vec![]);
assert!(ids.is_empty());
assert!(index.is_empty());
}
#[test]
fn batch_insert_saves_and_loads() {
let vectors = make_random_vectors(30, 6, 55);
let mut index = HnswIndex::new(8, 32, DistanceMetric::L2);
index.batch_insert(vectors.clone());
let bytes = index.to_hdf5_bytes().unwrap();
let loaded = HnswIndex::load_from_hdf5(&bytes).unwrap();
assert_eq!(loaded.len(), 30);
assert_eq!(loaded.metric(), DistanceMetric::L2);
// The query's own vector should be the nearest neighbor.
let q = &vectors[0];
let results = loaded.search(q, 1, 32);
assert_eq!(results[0].0, 0);
}
}