Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2053b69f07 | ||
|
|
b55b7dbac5 | ||
|
|
48c745a960 | ||
|
|
377c8b6f17 | ||
|
|
07b7301ded | ||
|
|
d3c65ccb58 | ||
|
|
b08df7b628 |
+11
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## v2.2.0 (2026-09-18)
|
||||
|
||||
### Security
|
||||
- `clawhdf5-format`: bounded decompression output (`MAX_DECOMPRESS_SIZE`) for
|
||||
@@ -245,6 +245,16 @@
|
||||
reading compound types and — critically — every chunked/compressed dataset
|
||||
written by HDF5 2.0. Found by running the h5py interop tests against
|
||||
h5py 3.16 / HDF5 2.0.
|
||||
Independently reported (with a patch) against the v2.1.0 tag by
|
||||
M. Scot Breitenfeld (The HDF Group) — v2.1.0 predates this fix.
|
||||
- `clawhdf5-format`: parse HDF5 2.0 native complex datatypes (class 11,
|
||||
datatype version 5, e.g. `H5T_COMPLEX_IEEE_F64LE`). The properties are a
|
||||
single base floating-point datatype, not a compound-style member list; the
|
||||
old parser read the base type's bytes as member names, producing a garbage
|
||||
datatype, and failed with `UnexpectedEof` when a complex type was nested in
|
||||
a compound. It is now surfaced as the equivalent `{r, i}` compound (the
|
||||
shape h5py writes for numpy complex dtypes), with a size check against the
|
||||
base type. Validated end-to-end against an HDF5 2.0-written file.
|
||||
|
||||
### Performance
|
||||
- `clawhdf5-format`: chunked writes now compress all chunks up front via
|
||||
|
||||
+2
-2
@@ -21,10 +21,10 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
|
||||
[workspace.dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-accel"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "SIMD-accelerated operations for rustyhdf5"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["hdf5", "simd", "acceleration", "performance"]
|
||||
categories = ["science", "algorithms"]
|
||||
|
||||
@@ -111,7 +111,7 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
}
|
||||
|
||||
let denom = (norm_a * norm_b).sqrt();
|
||||
if denom == 0.0 { 0.0 } else { dot / denom }
|
||||
if denom < f32::EPSILON { 0.0 } else { dot / denom }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
}
|
||||
|
||||
let denom = (norm_a * norm_b).sqrt();
|
||||
if denom == 0.0 { 0.0 } else { dot / denom }
|
||||
if denom < f32::EPSILON { 0.0 } else { dot / denom }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -361,6 +361,18 @@ mod tests {
|
||||
assert!(approx_eq(cosine_similarity(&a, &b), 0.0, EPSILON));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_near_zero_norm_clamped() {
|
||||
// denom = 1e-4 * 1e-4 = 1e-8, comfortably below f32::EPSILON
|
||||
// (~1.19e-7) but not exactly 0.0 — must still clamp to 0.0 so
|
||||
// callers computing `1.0 - cosine_similarity(...)` treat these
|
||||
// as maximally dissimilar, matching the pre-SIMD scalar guard.
|
||||
let a = [1e-4f32];
|
||||
let b = [1e-4f32];
|
||||
assert_eq!(cosine_similarity(&a, &b), 0.0);
|
||||
assert_eq!(scalar::cosine_similarity(&a, &b), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_scalar_vs_dispatch() {
|
||||
let a: Vec<f32> = (0..384).map(|i| (i as f32).sin()).collect();
|
||||
|
||||
@@ -94,7 +94,7 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
}
|
||||
|
||||
let denom = (norm_a * norm_b).sqrt();
|
||||
if denom == 0.0 { 0.0 } else { dot / denom }
|
||||
if denom < f32::EPSILON { 0.0 } else { dot / denom }
|
||||
}
|
||||
|
||||
/// NEON L2 distance.
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
norm_b += y * y;
|
||||
}
|
||||
let denom = (norm_a * norm_b).sqrt();
|
||||
if denom == 0.0 { 0.0 } else { dot / denom }
|
||||
if denom < f32::EPSILON { 0.0 } else { dot / denom }
|
||||
}
|
||||
|
||||
pub fn batch_cosine(query: &[f32], vectors: &[&[f32]], results: &mut [(usize, f32)]) {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
[package]
|
||||
name = "clawhdf5-agent"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "HDF5-backed persistent memory store for on-device AI agents"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["agent", "memory", "hdf5", "vector-search", "embedding"]
|
||||
categories = ["database", "science", "algorithms"]
|
||||
|
||||
[dependencies]
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0", features = ["parallel", "fast-checksum"] }
|
||||
clawhdf5 = { path = "../clawhdf5", version = "2.1.0" }
|
||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.1.0", features = ["mmap"] }
|
||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.1.0" }
|
||||
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.1.0", optional = true }
|
||||
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.1.0", optional = true, default-features = false }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0", features = ["parallel", "fast-checksum"] }
|
||||
clawhdf5 = { path = "../clawhdf5", version = "2.2.0" }
|
||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.2.0", features = ["mmap"] }
|
||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.2.0" }
|
||||
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.2.0", optional = true }
|
||||
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.2.0", optional = true, default-features = false }
|
||||
serde = { workspace = true }
|
||||
byteorder = "1"
|
||||
half = { workspace = true, optional = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawhdf5-android"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
[package]
|
||||
name = "clawhdf5-ann"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "HNSW approximate nearest neighbor index stored as HDF5"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["hdf5", "ann", "hnsw", "nearest-neighbor"]
|
||||
categories = ["algorithms", "science"]
|
||||
|
||||
[dependencies]
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
|
||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.1.0" }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
|
||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.2.0" }
|
||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.2.0" }
|
||||
rayon = { version = "1", optional = true }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -44,32 +44,14 @@ impl DistanceMetric {
|
||||
}
|
||||
|
||||
/// Compute distance between two vectors using the given metric.
|
||||
///
|
||||
/// Delegates to `clawhdf5-accel`'s runtime-dispatched SIMD kernels (AVX2 on
|
||||
/// x86_64, NEON on aarch64, portable scalar fallback elsewhere) — this is
|
||||
/// the hottest loop in both HNSW build and every `hybrid_search` query.
|
||||
fn compute_distance(a: &[f32], b: &[f32], metric: DistanceMetric) -> f32 {
|
||||
match metric {
|
||||
DistanceMetric::L2 => {
|
||||
let mut sum = 0.0f32;
|
||||
for i in 0..a.len() {
|
||||
let d = a[i] - b[i];
|
||||
sum += d * d;
|
||||
}
|
||||
sum.sqrt()
|
||||
}
|
||||
DistanceMetric::Cosine => {
|
||||
let mut dot = 0.0f32;
|
||||
let mut norm_a = 0.0f32;
|
||||
let mut norm_b = 0.0f32;
|
||||
for i in 0..a.len() {
|
||||
dot += a[i] * b[i];
|
||||
norm_a += a[i] * a[i];
|
||||
norm_b += b[i] * b[i];
|
||||
}
|
||||
let denom = norm_a.sqrt() * norm_b.sqrt();
|
||||
if denom < f32::EPSILON {
|
||||
1.0
|
||||
} else {
|
||||
1.0 - (dot / denom)
|
||||
}
|
||||
}
|
||||
DistanceMetric::L2 => clawhdf5_accel::l2_distance(a, b),
|
||||
DistanceMetric::Cosine => 1.0 - clawhdf5_accel::cosine_similarity(a, b),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1318,6 +1300,18 @@ mod tests {
|
||||
assert!((d - 1.0).abs() < 1e-6); // zero vector -> distance 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_near_zero_vector() {
|
||||
// Tiny-but-nonzero, identical-direction vectors: denom is well
|
||||
// below f32::EPSILON but not exactly 0.0. Must still be treated
|
||||
// as a degenerate/unreliable direction (distance 1, "maximally
|
||||
// dissimilar"), not as an exact match (distance 0).
|
||||
let a = vec![1e-4, 1e-4];
|
||||
let b = vec![1e-4, 1e-4];
|
||||
let d = compute_distance(&a, &b, DistanceMetric::Cosine);
|
||||
assert!((d - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_into_empty_index() {
|
||||
let mut index = HnswIndex::new(4, 16, DistanceMetric::L2);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawhdf5-bench"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-cli"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "CLI for clawhdf5 agent memory — create, save, search, recall, stats"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
keywords = ["hdf5", "ai", "memory", "agent", "cli"]
|
||||
categories = ["command-line-utilities", "science"]
|
||||
readme = "../../README.md"
|
||||
@@ -14,7 +14,7 @@ name = "clawhdf5"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.1.0" }
|
||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.2.0" }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
serde_json = "1"
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-derive"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "Derive macros for rustyhdf5 HDF5 traits"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["hdf5", "derive", "macros", "science"]
|
||||
categories = ["development-tools::procedural-macro-helpers"]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-filters"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "Filter and compression pipeline for clawhdf5"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["hdf5", "compression", "deflate", "filters"]
|
||||
categories = ["compression", "science"]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-format"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "Pure-Rust HDF5 binary format parsing and writing — no C dependencies"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["hdf5", "science", "data", "binary", "no-std"]
|
||||
categories = ["parser-implementations", "science", "encoding", "no-std"]
|
||||
@@ -25,7 +25,7 @@ pco = { version = "1.0", optional = true }
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
criterion = { workspace = true }
|
||||
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.1.0" }
|
||||
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.2.0" }
|
||||
|
||||
[[bench]]
|
||||
name = "bench"
|
||||
|
||||
@@ -546,27 +546,39 @@ impl Datatype {
|
||||
}
|
||||
}
|
||||
11 => {
|
||||
// Complex number — store as compound of two floats internally
|
||||
// Parse like compound with version 3 and 2 members
|
||||
// But actually class 11 has no special properties beyond class 6 compound.
|
||||
// It's just recognized as a separate class. For now parse the 2 members
|
||||
// as compound.
|
||||
let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
|
||||
let mut members = Vec::with_capacity(num_members as usize);
|
||||
let ob = offset_bytes_for_size(size);
|
||||
for _ in 0..num_members {
|
||||
let (name, name_len) = read_null_terminated_string(data, pos)?;
|
||||
pos += name_len;
|
||||
let byte_offset = read_uint(data, pos, ob)?;
|
||||
pos += ob;
|
||||
let (member_dt, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
|
||||
pos += consumed;
|
||||
members.push(CompoundMember {
|
||||
name,
|
||||
byte_offset,
|
||||
datatype: member_dt,
|
||||
// Complex number (HDF5 2.0, datatype version 5). The properties
|
||||
// are a single base floating-point datatype message; an element
|
||||
// is two consecutive base-type values (real, imaginary). There
|
||||
// is no member list. Surface it as the equivalent two-member
|
||||
// compound `{r, i}` — the same shape h5py writes for numpy
|
||||
// complex dtypes — so downstream compound readers work as-is.
|
||||
if version != 5 {
|
||||
return Err(FormatError::InvalidDatatypeVersion {
|
||||
class: class_id,
|
||||
version,
|
||||
});
|
||||
}
|
||||
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
|
||||
pos += consumed;
|
||||
let base_size = base_type.type_size();
|
||||
if base_size.checked_mul(2) != Some(size) {
|
||||
return Err(FormatError::DataSizeMismatch {
|
||||
expected: (base_size as usize).saturating_mul(2),
|
||||
actual: size as usize,
|
||||
});
|
||||
}
|
||||
let members = vec![
|
||||
CompoundMember {
|
||||
name: String::from("r"),
|
||||
byte_offset: 0,
|
||||
datatype: base_type.clone(),
|
||||
},
|
||||
CompoundMember {
|
||||
name: String::from("i"),
|
||||
byte_offset: base_size as u64,
|
||||
datatype: base_type,
|
||||
},
|
||||
];
|
||||
Ok((Datatype::Compound { size, members }, pos))
|
||||
}
|
||||
_ => Err(FormatError::InvalidDatatypeClass(class_id)),
|
||||
@@ -1126,6 +1138,75 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Real datatype message bytes emitted by HDF5 2.0 for the native complex
|
||||
/// type `H5T_COMPLEX_IEEE_F64LE`: class 11, version 5, size 16, followed by
|
||||
/// the base IEEE f64 datatype message.
|
||||
const COMPLEX_F64_HDF5_2_0: [u8; 28] = [
|
||||
0x5b, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x11, 0x20, 0x3f, 0x00, 0x08, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00, 0x34, 0xff, 0x03, 0x00, 0x00,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn test_complex_v5_from_hdf5_2_0() {
|
||||
let (dt, consumed) = Datatype::parse(&COMPLEX_F64_HDF5_2_0).unwrap();
|
||||
assert_eq!(consumed, COMPLEX_F64_HDF5_2_0.len());
|
||||
match dt {
|
||||
Datatype::Compound { size, members } => {
|
||||
assert_eq!(size, 16);
|
||||
assert_eq!(members.len(), 2);
|
||||
assert_eq!((members[0].name.as_str(), members[0].byte_offset), ("r", 0));
|
||||
assert_eq!((members[1].name.as_str(), members[1].byte_offset), ("i", 8));
|
||||
for m in &members {
|
||||
assert!(matches!(
|
||||
m.datatype,
|
||||
Datatype::FloatingPoint { size: 8, .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
other => panic!("expected Compound, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compound_with_complex_member_from_hdf5_2_0() {
|
||||
// Compound { z: complex f64 @0, k: i64 @16 } as written by HDF5 2.0.
|
||||
// Regression guard: the complex member must consume exactly its own
|
||||
// bytes so the following member parses.
|
||||
let mut bytes = vec![0x56, 0x02, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, b'z', 0x00, 0x00];
|
||||
bytes.extend_from_slice(&COMPLEX_F64_HDF5_2_0);
|
||||
bytes.extend_from_slice(&[b'k', 0x00, 0x10]);
|
||||
bytes.extend_from_slice(&[
|
||||
0x10, 0x08, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00,
|
||||
]);
|
||||
let (dt, consumed) = Datatype::parse(&bytes).unwrap();
|
||||
assert_eq!(consumed, bytes.len());
|
||||
match dt {
|
||||
Datatype::Compound { size, members } => {
|
||||
assert_eq!(size, 24);
|
||||
assert_eq!(members.len(), 2);
|
||||
assert!(matches!(
|
||||
&members[0].datatype,
|
||||
Datatype::Compound { size: 16, members } if members.len() == 2
|
||||
));
|
||||
assert_eq!((members[1].name.as_str(), members[1].byte_offset), ("k", 16));
|
||||
}
|
||||
other => panic!("expected Compound, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_size_mismatch_rejected() {
|
||||
let mut bytes = COMPLEX_F64_HDF5_2_0;
|
||||
bytes[4] = 0x0c; // claims 12 bytes, base type is 8
|
||||
assert!(matches!(
|
||||
Datatype::parse(&bytes),
|
||||
Err(FormatError::DataSizeMismatch {
|
||||
expected: 16,
|
||||
actual: 12
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reference_object() {
|
||||
let buf = build_dt_header(7, 1, [0, 0, 0], 8);
|
||||
|
||||
@@ -292,6 +292,74 @@ f.close()
|
||||
assert_eq!(x_vals, vec![1.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Python h5py module"]
|
||||
fn read_h5py_generated_native_complex() {
|
||||
// HDF5 2.0 native complex (datatype class 11, version 5), written through
|
||||
// h5py's low-level API. Skips when the linked HDF5 predates 2.0.
|
||||
let path = std::env::temp_dir().join("clawhdf5_h5py_native_complex.h5");
|
||||
let gen_script = format!(
|
||||
r#"
|
||||
import h5py, numpy as np
|
||||
from h5py import h5t, h5s, h5d, h5f, h5p
|
||||
if not getattr(h5py.get_config(), 'has_native_complex', False):
|
||||
print('SKIP')
|
||||
else:
|
||||
fapl = h5p.create(h5p.FILE_ACCESS)
|
||||
fapl.set_libver_bounds(h5f.LIBVER_LATEST, h5f.LIBVER_LATEST)
|
||||
fid = h5f.create(b'{}', h5f.ACC_TRUNC, fapl=fapl)
|
||||
t = h5t.COMPLEX_IEEE_F64LE
|
||||
d = h5d.create(fid, b'z', t, h5s.create_simple((2,)))
|
||||
d.write(h5s.ALL, h5s.ALL, np.array([1+2j, 3+4j], dtype=np.complex128), mtype=t)
|
||||
fid.close()
|
||||
"#,
|
||||
path.display()
|
||||
);
|
||||
if h5py_read(&path, &gen_script) == "SKIP" {
|
||||
eprintln!("HDF5 < 2.0: no native complex support, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let sig = clawhdf5_format::signature::find_signature(&bytes).unwrap();
|
||||
let sb = clawhdf5_format::superblock::Superblock::parse(&bytes, sig).unwrap();
|
||||
let addr = clawhdf5_format::group_v2::resolve_path_any(&bytes, &sb, "z").unwrap();
|
||||
let hdr = clawhdf5_format::object_header::ObjectHeader::parse(
|
||||
&bytes,
|
||||
addr as usize,
|
||||
sb.offset_size,
|
||||
sb.length_size,
|
||||
)
|
||||
.unwrap();
|
||||
let msg = |t: clawhdf5_format::message_type::MessageType| {
|
||||
&hdr.messages.iter().find(|m| m.msg_type == t).unwrap().data
|
||||
};
|
||||
let (dt, _) = clawhdf5_format::datatype::Datatype::parse(msg(
|
||||
clawhdf5_format::message_type::MessageType::Datatype,
|
||||
))
|
||||
.unwrap();
|
||||
let ds = clawhdf5_format::dataspace::Dataspace::parse(
|
||||
msg(clawhdf5_format::message_type::MessageType::Dataspace),
|
||||
sb.length_size,
|
||||
)
|
||||
.unwrap();
|
||||
let dl = clawhdf5_format::data_layout::DataLayout::parse(
|
||||
msg(clawhdf5_format::message_type::MessageType::DataLayout),
|
||||
sb.offset_size,
|
||||
sb.length_size,
|
||||
)
|
||||
.unwrap();
|
||||
let raw = clawhdf5_format::data_read::read_raw_data(&bytes, &dl, &ds, &dt).unwrap();
|
||||
let fields = clawhdf5_format::data_read::read_compound_fields(&raw, &dt).unwrap();
|
||||
assert_eq!(fields.len(), 2);
|
||||
let re =
|
||||
clawhdf5_format::data_read::read_as_f64(&fields[0].raw_data, &fields[0].datatype).unwrap();
|
||||
let im =
|
||||
clawhdf5_format::data_read::read_as_f64(&fields[1].raw_data, &fields[1].datatype).unwrap();
|
||||
assert_eq!((fields[0].name.as_str(), re), ("r", vec![1.0, 3.0]));
|
||||
assert_eq!((fields[1].name.as_str(), im), ("i", vec![2.0, 4.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Python h5py module"]
|
||||
fn read_h5py_generated_enum() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-gpu"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["hdf5", "gpu", "wgpu", "compute"]
|
||||
categories = ["science", "graphics"]
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
[package]
|
||||
name = "clawhdf5-io"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "I/O abstraction layer for rustyhdf5"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["hdf5", "io", "science", "data"]
|
||||
categories = ["filesystem", "science"]
|
||||
|
||||
[dependencies]
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
|
||||
memmap2 = { version = "0.9", optional = true }
|
||||
libc = { version = "0.2", optional = true }
|
||||
tokio = { version = "1", features = ["fs", "io-util"], optional = true }
|
||||
|
||||
@@ -59,11 +59,16 @@ pub trait AsyncHDF5Read: Send + Sync {
|
||||
|
||||
/// Async file-backed reader using tokio for non-blocking I/O.
|
||||
///
|
||||
/// Opens a file and reads it asynchronously. The file is read into memory
|
||||
/// on first access, making subsequent operations fast.
|
||||
/// Opens a file and reads it asynchronously. The underlying file handle is
|
||||
/// opened once (lazily, on first access) and cached for the lifetime of this
|
||||
/// reader, so repeated granular `read_at` calls reuse the open descriptor
|
||||
/// and cached length instead of paying an open+stat syscall pair every time.
|
||||
/// The handle is guarded by a mutex, which also correctly serializes the
|
||||
/// seek-then-read pairs of concurrent callers sharing the one file position.
|
||||
#[derive(Debug)]
|
||||
pub struct AsyncFileReader {
|
||||
path: std::path::PathBuf,
|
||||
handle: tokio::sync::Mutex<Option<(tokio::fs::File, u64)>>,
|
||||
}
|
||||
|
||||
impl AsyncFileReader {
|
||||
@@ -73,6 +78,7 @@ impl AsyncFileReader {
|
||||
pub fn new<P: AsRef<Path>>(path: P) -> Self {
|
||||
Self {
|
||||
path: path.as_ref().to_path_buf(),
|
||||
handle: tokio::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,23 +95,33 @@ impl AsyncFileReader {
|
||||
|
||||
impl AsyncHDF5Read for AsyncFileReader {
|
||||
async fn read_at(&self, offset: u64, len: usize) -> io::Result<Vec<u8>> {
|
||||
let mut file = tokio::fs::File::open(&self.path).await?;
|
||||
let metadata = file.metadata().await?;
|
||||
let file_len = metadata.len();
|
||||
let mut guard = self.handle.lock().await;
|
||||
if guard.is_none() {
|
||||
let file = tokio::fs::File::open(&self.path).await?;
|
||||
let file_len = file.metadata().await?.len();
|
||||
*guard = Some((file, file_len));
|
||||
}
|
||||
let (file, file_len) = guard.as_mut().expect("just populated above");
|
||||
let file_len = *file_len;
|
||||
if offset >= file_len {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let available = (file_len - offset) as usize;
|
||||
let to_read = len.min(available);
|
||||
tokio::io::AsyncSeekExt::seek(&mut file, io::SeekFrom::Start(offset)).await?;
|
||||
tokio::io::AsyncSeekExt::seek(file, io::SeekFrom::Start(offset)).await?;
|
||||
let mut buf = vec![0u8; to_read];
|
||||
file.read_exact(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
async fn len(&self) -> io::Result<u64> {
|
||||
let metadata = tokio::fs::metadata(&self.path).await?;
|
||||
Ok(metadata.len())
|
||||
let mut guard = self.handle.lock().await;
|
||||
if guard.is_none() {
|
||||
let file = tokio::fs::File::open(&self.path).await?;
|
||||
let file_len = file.metadata().await?.len();
|
||||
*guard = Some((file, file_len));
|
||||
}
|
||||
Ok(guard.as_ref().expect("just populated above").1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-migrate"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "CLI to migrate SQLite agent memory databases to HDF5 format"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["sqlite", "hdf5", "migration", "agent", "memory"]
|
||||
categories = ["command-line-utilities", "database"]
|
||||
@@ -14,9 +14,9 @@ name = "clawhdf5-migrate"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.1.0" }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
|
||||
clawhdf5 = { path = "../clawhdf5", version = "2.1.0" }
|
||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.2.0" }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
|
||||
clawhdf5 = { path = "../clawhdf5", version = "2.2.0" }
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
half = { workspace = true }
|
||||
|
||||
@@ -49,6 +49,10 @@ pub fn read_hdf5(path: &str) -> Result<SqliteData, BoxErr> {
|
||||
entities,
|
||||
relations,
|
||||
embedding_dim,
|
||||
// Not a SQLite read — the caller (incremental migration) carries
|
||||
// forward the current run's actual `source_path` from the fresh
|
||||
// SQLite read instead of using this placeholder.
|
||||
source_path: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ pub fn write_hdf5(
|
||||
opts: &WriteOptions,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut builder = FileBuilder::new();
|
||||
let timestamp = iso8601_now();
|
||||
|
||||
// Root-level metadata attributes
|
||||
builder.set_attr("agent_id", AttrValue::String(opts.agent_id.clone()));
|
||||
@@ -27,8 +28,18 @@ pub fn write_hdf5(
|
||||
builder.set_attr("embedding_dim", AttrValue::I64(data.embedding_dim as i64));
|
||||
builder.set_attr("source", AttrValue::String("sqlite-migration".into()));
|
||||
builder.set_attr("version", AttrValue::I64(1));
|
||||
// Lineage: which SQLite database this output was migrated from and when,
|
||||
// plus the migrator tool version — so a chain of `--incremental` runs
|
||||
// still has an audit trail instead of every run overwriting the same
|
||||
// static attributes (see research/03_provenance.md, INT-03).
|
||||
builder.set_attr("source_path", AttrValue::String(data.source_path.clone()));
|
||||
builder.set_attr("migrated_at", AttrValue::String(timestamp.clone()));
|
||||
builder.set_attr(
|
||||
"migrator_version",
|
||||
AttrValue::String(env!("CARGO_PKG_VERSION").to_owned()),
|
||||
);
|
||||
|
||||
write_chunks_group(&mut builder, data, opts);
|
||||
write_chunks_group(&mut builder, data, opts, ×tamp);
|
||||
write_sessions_group(&mut builder, data);
|
||||
write_entities_group(&mut builder, data);
|
||||
write_relations_group(&mut builder, data);
|
||||
@@ -37,6 +48,36 @@ pub fn write_hdf5(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current UTC time formatted as an ISO-8601 / RFC-3339 timestamp
|
||||
/// (`YYYY-MM-DDTHH:MM:SSZ`), with no external date/time dependency.
|
||||
fn iso8601_now() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let days = (secs / 86_400) as i64;
|
||||
let time_of_day = secs % 86_400;
|
||||
let (h, m, s) = (time_of_day / 3600, (time_of_day % 3600) / 60, time_of_day % 60);
|
||||
let (y, mo, d) = civil_from_days(days);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
/// Days-since-epoch to (year, month, day), Howard Hinnant's `civil_from_days`
|
||||
/// algorithm (proleptic Gregorian calendar, valid for the full `i64` range).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
|
||||
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
/// Build a fixed-length string Datatype from the max byte length of the items.
|
||||
fn string_dtype(max_len: usize) -> Datatype {
|
||||
Datatype::String {
|
||||
@@ -66,7 +107,12 @@ fn apply_compression(ds: &mut clawhdf5_format::type_builders::DatasetBuilder, op
|
||||
}
|
||||
}
|
||||
|
||||
fn write_chunks_group(builder: &mut FileBuilder, data: &SqliteData, opts: &WriteOptions) {
|
||||
fn write_chunks_group(
|
||||
builder: &mut FileBuilder,
|
||||
data: &SqliteData,
|
||||
opts: &WriteOptions,
|
||||
timestamp: &str,
|
||||
) {
|
||||
let mut group = builder.create_group("chunks");
|
||||
let n = data.chunks.len() as u64;
|
||||
|
||||
@@ -78,6 +124,16 @@ fn write_chunks_group(builder: &mut FileBuilder, data: &SqliteData, opts: &Write
|
||||
|
||||
group.set_attr("count", AttrValue::I64(n as i64));
|
||||
|
||||
// Source attribution attached directly to the content-bearing datasets
|
||||
// (SHA-256 of the raw bytes + creator/timestamp/source), so the chunk
|
||||
// text and embeddings each carry their own verifiable provenance
|
||||
// (see clawhdf5_format::provenance / `Dataset::verify_provenance`).
|
||||
let source_opt = if data.source_path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(data.source_path.as_str())
|
||||
};
|
||||
|
||||
// ids
|
||||
let ids: Vec<i64> = data.chunks.iter().map(|c| c.id).collect();
|
||||
group.create_dataset("id").with_i64_data(&ids);
|
||||
@@ -87,7 +143,8 @@ fn write_chunks_group(builder: &mut FileBuilder, data: &SqliteData, opts: &Write
|
||||
let (text_raw, text_len) = pack_strings(&texts);
|
||||
group
|
||||
.create_dataset("text")
|
||||
.with_compound_data(string_dtype(text_len), text_raw, n);
|
||||
.with_compound_data(string_dtype(text_len), text_raw, n)
|
||||
.with_provenance("clawhdf5-migrate", timestamp, source_opt);
|
||||
|
||||
// embeddings - flatten to [N, dim]
|
||||
let dim = data.embedding_dim;
|
||||
@@ -116,7 +173,8 @@ fn write_chunks_group(builder: &mut FileBuilder, data: &SqliteData, opts: &Write
|
||||
let ds = group
|
||||
.create_dataset("embeddings")
|
||||
.with_compound_data(f16_dtype, raw, n)
|
||||
.with_shape(&[n, dim as u64]);
|
||||
.with_shape(&[n, dim as u64])
|
||||
.with_provenance("clawhdf5-migrate", timestamp, source_opt);
|
||||
apply_compression(ds, opts);
|
||||
} else {
|
||||
let flat: Vec<f32> = data
|
||||
@@ -127,7 +185,8 @@ fn write_chunks_group(builder: &mut FileBuilder, data: &SqliteData, opts: &Write
|
||||
let ds = group
|
||||
.create_dataset("embeddings")
|
||||
.with_f32_data(&flat)
|
||||
.with_shape(&[n, dim as u64]);
|
||||
.with_shape(&[n, dim as u64])
|
||||
.with_provenance("clawhdf5-migrate", timestamp, source_opt);
|
||||
apply_compression(ds, opts);
|
||||
}
|
||||
|
||||
@@ -274,3 +333,30 @@ fn write_relations_group(builder: &mut FileBuilder, data: &SqliteData) {
|
||||
|
||||
builder.add_group(group.finish());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod time_tests {
|
||||
use super::civil_from_days;
|
||||
|
||||
#[test]
|
||||
fn epoch_day_zero_is_1970_01_01() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_dates_roundtrip() {
|
||||
// 2026-08-16 is 20,681 days after 1970-01-01.
|
||||
assert_eq!(civil_from_days(20_681), (2026, 8, 16));
|
||||
// 2000-02-29 (leap day itself) and 2000-03-01 (the day after).
|
||||
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
|
||||
assert_eq!(civil_from_days(11_017), (2000, 3, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_now_has_expected_shape() {
|
||||
let ts = super::iso8601_now();
|
||||
assert_eq!(ts.len(), "2026-08-16T00:00:00Z".len());
|
||||
assert!(ts.starts_with("20")); // sanity: 21st-century year
|
||||
assert!(ts.ends_with('Z'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
base.entities = source.entities;
|
||||
base.relations = source.relations;
|
||||
base.embedding_dim = source.embedding_dim.max(base.embedding_dim);
|
||||
// Carry the current run's real SQLite source forward for
|
||||
// provenance — `base` (re-read from the prior HDF5 output) has
|
||||
// no meaningful source_path of its own.
|
||||
base.source_path = source.source_path;
|
||||
if cli.verbose {
|
||||
eprintln!("Incremental: appended {added} new chunks (id > {min_chunk_id})");
|
||||
}
|
||||
@@ -199,6 +203,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
summary.embedding_dim,
|
||||
summary.rows_checked,
|
||||
);
|
||||
if summary.provenance_verified {
|
||||
eprintln!("Provenance: chunks/text and chunks/embeddings SHA-256 hashes verified.");
|
||||
} else if cli.verbose {
|
||||
eprintln!("Provenance: no provenance hash found to verify (older output format?).");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ pub struct SqliteData {
|
||||
pub entities: Vec<Entity>,
|
||||
pub relations: Vec<Relation>,
|
||||
pub embedding_dim: usize,
|
||||
/// Filesystem path of the SQLite database this data was read from, for
|
||||
/// provenance attribution on the HDF5 output. Empty when the data did
|
||||
/// not come directly from a SQLite read (e.g. re-read of a prior HDF5
|
||||
/// migration output for an incremental merge).
|
||||
pub source_path: String,
|
||||
}
|
||||
|
||||
/// A table name plus the ordered column names the reader maps by position.
|
||||
@@ -225,6 +230,7 @@ pub fn read_sqlite_filtered(
|
||||
entities,
|
||||
relations,
|
||||
embedding_dim: dim,
|
||||
source_path: path.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use clawhdf5::reader::File as Hdf5File;
|
||||
use clawhdf5_format::provenance::VerifyResult;
|
||||
|
||||
use crate::hdf5_reader::read_hdf5;
|
||||
use crate::sqlite_reader::SqliteData;
|
||||
|
||||
@@ -13,6 +16,12 @@ pub struct ValidationSummary {
|
||||
pub embedding_dim: u64,
|
||||
/// Number of rows whose full content was compared against the source.
|
||||
pub rows_checked: u64,
|
||||
/// Whether the `chunks/text` and `chunks/embeddings` SHINES provenance
|
||||
/// hashes (written via [`crate::hdf5_writer`]) were both present and
|
||||
/// matched their recomputed SHA-256 on read-back. `false` when either
|
||||
/// dataset has no provenance metadata (e.g. an older output file) or
|
||||
/// there are zero chunks to check.
|
||||
pub provenance_verified: bool,
|
||||
}
|
||||
|
||||
/// Validate a migrated HDF5 file against the source data.
|
||||
@@ -30,6 +39,7 @@ pub fn validate_hdf5(
|
||||
float16: bool,
|
||||
) -> Result<ValidationSummary, BoxErr> {
|
||||
let got = read_hdf5(path)?;
|
||||
let provenance_verified = verify_chunk_provenance(path)?;
|
||||
|
||||
// ---- Counts ----
|
||||
check_count("chunk", got.chunks.len(), source.chunks.len())?;
|
||||
@@ -126,6 +136,7 @@ pub fn validate_hdf5(
|
||||
relations: got.relations.len() as u64,
|
||||
embedding_dim: got.embedding_dim as u64,
|
||||
rows_checked,
|
||||
provenance_verified,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,6 +147,42 @@ fn check_count(kind: &str, got: usize, expected: usize) -> Result<(), BoxErr> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-verify the SHA-256 provenance hash of `chunks/text` and
|
||||
/// `chunks/embeddings` against their actual stored bytes, catching
|
||||
/// post-write corruption that a plain content comparison against the
|
||||
/// in-memory source wouldn't (the source is compared against what
|
||||
/// `read_hdf5` decoded, not against the raw bytes on disk).
|
||||
///
|
||||
/// Returns `Ok(true)` only if both datasets exist and both hashes match.
|
||||
/// Returns `Ok(false)` (not an error) if a dataset has no provenance
|
||||
/// attributes at all (e.g. a file written before this check existed) or
|
||||
/// there are zero chunks. Returns an error only on an actual hash mismatch —
|
||||
/// that indicates real corruption.
|
||||
fn verify_chunk_provenance(path: &str) -> Result<bool, BoxErr> {
|
||||
let file = Hdf5File::open(path)?;
|
||||
let Ok(chunks) = file.group("chunks") else {
|
||||
return Ok(false);
|
||||
};
|
||||
let mut all_present = true;
|
||||
for name in ["text", "embeddings"] {
|
||||
let Ok(ds) = chunks.dataset(name) else {
|
||||
all_present = false;
|
||||
continue;
|
||||
};
|
||||
match ds.verify_provenance()? {
|
||||
VerifyResult::Ok => {}
|
||||
VerifyResult::NoHash => all_present = false,
|
||||
VerifyResult::Mismatch { stored, computed } => {
|
||||
return Err(format!(
|
||||
"provenance hash mismatch on chunks/{name}: stored {stored}, recomputed {computed} — data may be corrupted"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_present)
|
||||
}
|
||||
|
||||
fn field_err<T: std::fmt::Display>(kind: &str, i: usize, field: &str, s: T, g: T) -> BoxErr {
|
||||
format!("{kind}[{i}].{field} mismatch: source {s}, HDF5 {g}").into()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
[package]
|
||||
name = "clawhdf5-napi"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "Node.js native addon (napi-rs) exposing clawhdf5-agent to TypeScript/JavaScript"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.1.0" }
|
||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.2.0" }
|
||||
napi = { version = "2", default-features = false, features = ["napi9"] }
|
||||
napi-derive = "2"
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
[package]
|
||||
name = "clawhdf5-netcdf4"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["netcdf", "netcdf4", "hdf5", "science", "climate"]
|
||||
categories = ["parser-implementations", "science"]
|
||||
|
||||
[dependencies]
|
||||
clawhdf5 = { path = "../clawhdf5", version = "2.1.0" }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
|
||||
clawhdf5 = { path = "../clawhdf5", version = "2.2.0" }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-py"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["hdf5", "python", "bindings", "science"]
|
||||
categories = ["api-bindings", "science"]
|
||||
@@ -14,8 +14,8 @@ name = "clawhdf5"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
clawhdf5_rs = { path = "../clawhdf5", version = "2.1.0", package = "clawhdf5" }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
|
||||
clawhdf5_rs = { path = "../clawhdf5", version = "2.2.0", package = "clawhdf5" }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
|
||||
pyo3 = "0.29"
|
||||
numpy = "0.29"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "rustyhdf5"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
||||
requires-python = ">=3.8"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
[package]
|
||||
name = "clawhdf5"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
description = "Pure-Rust HDF5 reader/writer — no C dependencies"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
readme = "README.md"
|
||||
keywords = ["hdf5", "science", "data", "binary"]
|
||||
categories = ["parser-implementations", "science", "encoding"]
|
||||
|
||||
[dependencies]
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
|
||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.1.0" }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
|
||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.2.0" }
|
||||
rayon = { version = "1", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
criterion = { workspace = true }
|
||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.1.0", features = ["mmap"] }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0", features = ["parallel", "fast-checksum"] }
|
||||
clawhdf5-filters = { path = "../clawhdf5-filters", version = "2.1.0" }
|
||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.2.0", features = ["mmap"] }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0", features = ["parallel", "fast-checksum"] }
|
||||
clawhdf5-filters = { path = "../clawhdf5-filters", version = "2.2.0" }
|
||||
|
||||
[[bench]]
|
||||
name = "mmap_bench"
|
||||
|
||||
@@ -426,6 +426,7 @@ impl<'f> Dataset<'f> {
|
||||
Ok(data_read::read_as_strings(&raw, &dt)?)
|
||||
}
|
||||
|
||||
|
||||
// ----- Selection-based read methods -----
|
||||
|
||||
/// Read selected elements as raw bytes.
|
||||
|
||||
+1
-1
@@ -556,7 +556,7 @@ let final_results = confidence::reject_low_confidence(
|
||||
|
||||
- **[BENCHMARKS.md](../BENCHMARKS.md)** — Full performance numbers
|
||||
- **[ROADMAP.md](../ROADMAP.md)** — What's coming next
|
||||
- **[GitHub](https://github.com/redclawsystems/clawhdf5)** — Source code
|
||||
- **[Source](https://git.redclaw.dev/quantumclaw/clawhdf5)** — Source code
|
||||
- **[ClawBrainHub](https://clawbrainhub.com)** — The `.brain` marketplace (coming soon)
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Known Issues
|
||||
|
||||
Bugs found during development or downstream use, tracked here because this
|
||||
repository's issue tracker is disabled. One entry per bug; when an entry is
|
||||
fixed, record the fix in `CHANGELOG.md` and update its status here rather than
|
||||
deleting it.
|
||||
|
||||
---
|
||||
|
||||
## Compound datatype message version 5 is not parsed (HDF5 2.0)
|
||||
|
||||
**Status:** fixed on `main` in `a13ff51` (2026-06-03); **not in the v2.1.0
|
||||
tag**, which was cut five commits earlier. Ships in the next release.
|
||||
|
||||
**Reported by:** M. Scot Breitenfeld (The HDF Group), 2026-09-08, against v2.1.0.
|
||||
|
||||
**Summary:** `clawhdf5-format` v2.1.0 rejects any dataset with a compound
|
||||
(struct) datatype written by an HDF5 2.0 library in `libver='latest'` mode:
|
||||
`InvalidDatatypeVersion { class: 6, version: 5 }`.
|
||||
|
||||
**Reproduction** (h5py 3.16.0 / HDF5 2.0.0):
|
||||
|
||||
```python
|
||||
import h5py, numpy as np
|
||||
dt = np.dtype([('x', 'f8'), ('y', 'f8'), ('id', 'i4')])
|
||||
data = np.array([(1.0, 2.0, 10), (3.0, 4.0, 20)], dtype=dt)
|
||||
f = h5py.File('compound.h5', 'w', libver='latest')
|
||||
f.create_dataset('particles', data=data)
|
||||
f.close()
|
||||
```
|
||||
|
||||
Committed as `crates/clawhdf5-format/tests/writer_h5py_tests.rs::read_h5py_generated_compound`
|
||||
(`#[ignore]`d; needs `python3` with h5py on `PATH`). Run with
|
||||
`cargo test -p clawhdf5-format --test writer_h5py_tests -- --include-ignored`:
|
||||
v2.1.0 gives 25 passed / 1 failed; `main` passes everything.
|
||||
|
||||
**Root cause:** the compound (class 6) branch of `Datatype::parse`
|
||||
(`crates/clawhdf5-format/src/datatype.rs`) accepted only versions 1–4. Datatype
|
||||
message versions 4 and 5 changed only the Reference and Complex classes, so a
|
||||
v5-tagged compound uses the unchanged v3 member-list layout.
|
||||
|
||||
**Fix:** versions 3–5 are accepted for compound (class 6) and array (class 10)
|
||||
datatypes, and data layout message version 5 is accepted too (needed for every
|
||||
chunked dataset written by HDF5 2.0). Byte-level regression tests:
|
||||
`test_compound_v5_from_hdf5_2_0`, `test_array_v5_from_hdf5_2_0`.
|
||||
|
||||
## Native complex datatype (class 11) is mis-parsed (HDF5 2.0)
|
||||
|
||||
**Status:** fixed 2026-09-18. Found while validating the report above.
|
||||
|
||||
**Summary:** HDF5 2.0 native complex types (`H5T_COMPLEX_IEEE_F64LE` etc.)
|
||||
were parsed as if they carried a compound-style member list. The properties are
|
||||
actually a single base floating-point datatype, so the parser produced a garbage
|
||||
datatype, or `UnexpectedEof` when the complex type was a compound member. h5py's
|
||||
default numpy-complex mapping is unaffected (it writes a `{r, i}` compound);
|
||||
only files using the native type through the C API / h5py low-level API hit this.
|
||||
|
||||
**Fix:** class 11 parses its base type and is surfaced as the equivalent
|
||||
`{r, i}` compound. Tests: `test_complex_v5_from_hdf5_2_0`,
|
||||
`test_compound_with_complex_member_from_hdf5_2_0`,
|
||||
`writer_h5py_tests.rs::read_h5py_generated_native_complex`.
|
||||
|
||||
## Revised reference datatype (class 7, version 4) is not parsed
|
||||
|
||||
**Status:** open, unconfirmed against a real file.
|
||||
|
||||
**Summary:** HDF5 1.12+ `H5T_STD_REF` references use datatype version 4 with
|
||||
reference types 2–4 (object2 / region2 / attribute), which `Datatype::parse`
|
||||
rejects with `InvalidReferenceType`. h5py still writes the legacy v1
|
||||
object/region references, which read correctly, so no reproducing file has been
|
||||
generated yet; one written with the C API (`H5T_STD_REF`) is needed.
|
||||
|
||||
## `clawhdf5-gpu` `gpu_tests` can hang under the default parallel test runner
|
||||
|
||||
**Status:** open. Observed 2026-09-18 (RTX 5060 Ti, Linux).
|
||||
|
||||
**Summary:** during `cargo test --workspace`, the `gpu_tests` binary sat idle
|
||||
(~1% CPU) for 25+ minutes and had to be killed. Run single-threaded it passes
|
||||
in seconds (20/20): `cargo test -p clawhdf5-gpu --test gpu_tests -- --test-threads=1`.
|
||||
Suspected cause: several tests creating wgpu devices concurrently (possibly
|
||||
compounded by the rest of the workspace's tests loading the machine). Not yet
|
||||
root-caused; workaround is `--test-threads=1` for that crate.
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@redclaw/clawhdf5",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"description": "Node.js bindings for clawhdf5 — HDF5-backed agent memory with hippocampal consolidation",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/redclawsystems/clawhdf5"
|
||||
"url": "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
},
|
||||
"keywords": [
|
||||
"agent",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Research: Performance — clawhdf5
|
||||
|
||||
Scope: opportunities not already covered by the Tier 1-4 hardening passes
|
||||
recorded in `ROADMAP.md`/`CHANGELOG.md`/`IMPROVEMENT_LOG.md` (O(1) chunk
|
||||
cache, rayon-parallel `prune_connections`, workspace-hoisted deps, etc).
|
||||
|
||||
## Finding P1 — HNSW's hot distance loop is scalar despite an existing SIMD crate
|
||||
|
||||
**Location:** `crates/clawhdf5-ann/src/hnsw.rs:47-74` (`compute_distance`), called
|
||||
from `greedy_closest` and `search_layer` — the innermost loop of both index
|
||||
build and every `hybrid_search` query.
|
||||
|
||||
**Problem:** `compute_distance` is a plain per-component `for i in 0..a.len()`
|
||||
scalar loop for both the `L2` and `Cosine` metrics. The workspace already ships
|
||||
`clawhdf5-accel` with runtime-dispatched AVX2/NEON/scalar-fallback
|
||||
`l2_distance`/`cosine_similarity` (`crates/clawhdf5-accel/src/lib.rs:125,148`),
|
||||
and `clawhdf5-agent` already depends on and uses it for its own linear cosine
|
||||
scan. `clawhdf5-ann/Cargo.toml` simply never lists `clawhdf5-accel` as a
|
||||
dependency, so the ANN crate — the one place with the tightest, most-called
|
||||
distance loop in the whole codebase — is the one place not using it.
|
||||
|
||||
**Fix implemented (INT-01):** Added `clawhdf5-accel` as a dependency of
|
||||
`clawhdf5-ann` and rewired `compute_distance` to call
|
||||
`clawhdf5_accel::l2_distance` / `clawhdf5_accel::cosine_similarity` (mapping
|
||||
`1.0 - similarity` for the cosine-distance semantics the rest of the file
|
||||
expects). The accel crate already carries its own scalar fallback for
|
||||
platforms without AVX2/NEON, so no separate fallback branch is needed here.
|
||||
Existing `hnsw.rs` unit tests (build/search/serialize round-trip) validate
|
||||
behavior is unchanged; no format or public-API change.
|
||||
|
||||
## Finding P2 — `AsyncFileReader::read_at` reopens and re-stats the file on every call
|
||||
|
||||
**Location:** `crates/clawhdf5-io/src/async_read.rs:90-104`.
|
||||
|
||||
**Problem:** Each `read_at` call does `tokio::fs::File::open` +
|
||||
`.metadata()` + `seek` + `read_exact` — two extra syscalls (open + stat) on
|
||||
every single granular read, with no persistent handle and no buffering. This
|
||||
directly defeats the purpose of the "chunked/granular async access" this type
|
||||
is documented for; callers doing many small reads (e.g. chunked dataset
|
||||
iteration) pay file-open overhead per chunk.
|
||||
|
||||
**Fix implemented (INT-02):** `AsyncFileReader` now lazily opens the file
|
||||
once and caches the open handle (plus its length) behind a `tokio::sync::Mutex`,
|
||||
so subsequent `read_at`/`len` calls reuse the already-open descriptor instead
|
||||
of reopening. First call pays one open+stat; every call after is just a
|
||||
seek+read (or a length lookup with no syscall at all, since length is cached
|
||||
at open time). Behavior (including short-read truncation semantics) is
|
||||
unchanged and covered by the existing `async_file_reader_*` tests.
|
||||
|
||||
## Not implemented — flagged for follow-up
|
||||
|
||||
- **HNSW build-loop parallelism** (`hnsw.rs` insert loop) — ROADMAP already
|
||||
notes this needs its own correctness-sensitive design pass (insert order
|
||||
affects the graph, unlike `prune_connections`'s embarrassingly-parallel
|
||||
per-node distance computation). Left as-is; out of scope for this pass.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Research: Security — clawhdf5
|
||||
|
||||
Scope: opportunities not already covered by the shipped hardening (WAL CRC32
|
||||
trailer / `WAL_VERSION` 2, `MAX_WAL_FIELD_LEN` field caps, Android JNI length
|
||||
validation, `chunked_read.rs`/`data_read.rs` bounds-check + fuzz pass,
|
||||
decompression-bomb output bound, etc — see `ROADMAP.md`).
|
||||
|
||||
## Finding S1 — WAL v2 still allocates untrusted field buffers before the CRC32 check runs
|
||||
|
||||
**Location:** `crates/clawhdf5-agent/src/wal.rs`, entry read path
|
||||
(`read_len_prefixed_str`/`read_embedding` helpers feeding into the `Save`
|
||||
entry parser around lines 340-380; CRC verification happens afterward at
|
||||
~lines 246-255).
|
||||
|
||||
**Problem:** Each `Save` entry currently contains three independent
|
||||
length-prefixed strings plus one length-prefixed embedding buffer. Each field
|
||||
is capped individually at `MAX_WAL_FIELD_LEN` (64 MiB) — but that cap is
|
||||
checked and then the buffer is **allocated immediately** as each field's
|
||||
length prefix is read, before the entry's trailing CRC32 is ever checked. A
|
||||
single corrupted entry (bit-flipped length prefixes) can therefore force up
|
||||
to ~4 allocations near 64 MiB each (~256 MB) before the CRC finally rejects
|
||||
it. This is exactly what `ROADMAP.md`'s "What's Next" section already flags
|
||||
as open: *"a stronger per-entry format (explicit length prefix, avoiding the
|
||||
read-then-verify restructuring) could still be revisited."*
|
||||
|
||||
**Why not implemented in this pass:** Fixing this properly means a WAL format
|
||||
version bump (`WAL_VERSION` 3): frame each entry as one outer
|
||||
`[total_len: u32][entry_bytes][crc32: u32]`, read+CRC-check the whole raw
|
||||
entry buffer *first*, and only then parse the individual fields out of the
|
||||
already-verified buffer — mirroring the v1→v2 migration this file already
|
||||
does on open. That's a real, self-contained, well-testable change (the file
|
||||
already has a legacy-format migration test harness and corruption-detection
|
||||
tests to extend), but it touches the on-disk framing and the read/write pair
|
||||
needs to stay in lock-step, so it deserves its own dedicated
|
||||
implement-and-test pass rather than being bundled in alongside unrelated
|
||||
performance/provenance changes. Tracked as **INT-04** below for follow-up.
|
||||
|
||||
## Finding S2 — no dataset-level integrity check on the agent memory read path
|
||||
|
||||
See `research/03_provenance.md` finding PR2 (`INT-06`) — closely related to
|
||||
security (corruption detection on read), tracked there since the mechanism
|
||||
(`ProvenanceStore::verify_integrity`) is a provenance primitive.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Research: Provenance — clawhdf5
|
||||
|
||||
Scope: data lineage, source attribution, and tamper-evidence for both the
|
||||
low-level HDF5 format layer and the higher-level agent-memory / migration
|
||||
tools built on top of it.
|
||||
|
||||
## Finding PR1 — SHINES provenance (SHA-256 + creator/timestamp/source) is fully built and tested, but zero production write paths use it
|
||||
|
||||
**Location:** `crates/clawhdf5-format/src/provenance.rs` (the whole module —
|
||||
`Provenance::build_attrs`, `sha256_hex`, `verify_dataset`) and
|
||||
`crates/clawhdf5-format/src/type_builders.rs:671-686`
|
||||
(`DatasetBuilder::with_provenance`, feature-gated on `provenance`, which is
|
||||
**on by default** in `clawhdf5-format`).
|
||||
|
||||
**Problem:** This is a complete, working, already-tested feature — it writes
|
||||
`_provenance_sha256` / `_provenance_creator` / `_provenance_timestamp` /
|
||||
`_provenance_source` attributes on a dataset and can re-verify the hash later
|
||||
via `verify_dataset`. `grep -rl with_provenance crates/` shows it is
|
||||
exercised only by `clawhdf5-format`'s own tests/benches
|
||||
(`tests/robustness_tests.rs`, `tests/writer_h5py_tests.rs`,
|
||||
`benches/bench.rs`). Neither `clawhdf5-agent` (the memory backend) nor
|
||||
`clawhdf5-migrate` (the SQLite→HDF5 migration tool — the one place data
|
||||
crosses a genuine trust/source boundary) calls it. Concretely,
|
||||
`crates/clawhdf5-migrate/src/hdf5_writer.rs:24-28` sets only a handful of
|
||||
static root attributes (`agent_id`, `embedder`, `embedding_dim`, a *constant*
|
||||
`source="sqlite-migration"`, a *constant* `version=1`) — there is no source
|
||||
file path, no content hash of the source database, no migration timestamp,
|
||||
and `--incremental` runs (`main.rs` ~122-133) overwrite these same static
|
||||
attributes on every append, so a chain of incremental merges leaves no audit
|
||||
trail: a corrupted incremental append is indistinguishable after the fact
|
||||
from a clean one.
|
||||
|
||||
**Fix implemented (INT-03):** Wired the *existing* SHINES provenance
|
||||
mechanism into the migration write path instead of inventing a new one:
|
||||
|
||||
- `clawhdf5-migrate/src/hdf5_writer.rs`: the `embeddings` and `text` chunk
|
||||
datasets are now built with `.with_provenance("clawhdf5-migrate", <RFC3339
|
||||
timestamp>, Some(<source sqlite path>))`, so each migrated dataset carries
|
||||
a verifiable SHA-256 of its own bytes plus who/when/where it came from.
|
||||
- `clawhdf5-migrate/src/sqlite_reader.rs`: `SqliteData` gained a
|
||||
`source_path: String` field (the SQLite path actually read), threaded
|
||||
through `read_sqlite_filtered`.
|
||||
- `clawhdf5-migrate/src/main.rs`: the incremental-merge arm now carries the
|
||||
*current* run's `source_path` forward instead of silently keeping
|
||||
whatever the previous run recorded.
|
||||
- `clawhdf5-migrate/src/validate.rs`: `validate_hdf5` now also calls
|
||||
`clawhdf5_format::provenance::verify_dataset` on the embeddings dataset and
|
||||
fails validation on a hash mismatch, so migration validation catches
|
||||
post-write corruption, not just source/dest content drift.
|
||||
|
||||
This directly closes the exact gap ROADMAP's "What's Next" implicitly left
|
||||
open (migration recorded no real lineage) using code that was already
|
||||
shipped, tested, and sitting unused one crate over — no new format version,
|
||||
no new dependency, minimal blast radius (2 struct-literal sites for the new
|
||||
`SqliteData` field, both updated).
|
||||
|
||||
## Finding PR2 — agent-level `MemoryProvenance`/`AnomalyDetector` are dead code on the real save path (ROADMAP claims Track 5 "complete")
|
||||
|
||||
**Location:** `crates/clawhdf5-agent/src/lib.rs` (`HDF5Memory::save` /
|
||||
`save_batch`, ~lines 538-572); `crates/clawhdf5-agent/src/provenance.rs`
|
||||
(`MemoryProvenance`, `ProvenanceStore::verify_integrity`/`mark_verified`);
|
||||
`crates/clawhdf5-agent/src/anomaly.rs` (`AnomalyDetector::check_rate_anomaly`
|
||||
/ `check_pattern_anomaly` / `check_source_anomaly`).
|
||||
|
||||
**Problem:** `ROADMAP.md` Track 5 ("Memory Security & Provenance") is marked
|
||||
🟢 Complete, but `save()`/`save_batch()` push straight into the in-memory
|
||||
cache + WAL without ever constructing a `MemoryProvenance` record, without
|
||||
ever calling any `AnomalyDetector` check, and without going through
|
||||
`SourceIsolation`. A `grep` for `provenance::`/`anomaly::` usage across the
|
||||
crate turns up only each module's own `#[cfg(test)]` block. So today a
|
||||
forged- or poisoned-source memory write is stored and later retrieved with
|
||||
zero attribution and zero anomaly screening, contradicting the shipped-status
|
||||
claim in the docs.
|
||||
|
||||
**Why not implemented in this pass:** This is a real fix, but it is
|
||||
core-save-path surgery — it has to interact correctly with the WAL replay
|
||||
path (a provenance record written to cache but not WAL, or vice versa, would
|
||||
silently desync memory from the durable log on crash-recovery) and with
|
||||
`save_batch`'s different code path from `save`. That needs its own focused
|
||||
implement-and-test pass with the existing `provenance.rs`/`anomaly.rs` unit
|
||||
tests as a base, rather than being bundled in under time pressure alongside
|
||||
unrelated changes. Tracked as **INT-05** below.
|
||||
|
||||
## Finding PR3 — nothing on the retrieval path ever calls `verify_integrity`
|
||||
|
||||
**Location:** `crates/clawhdf5-agent/src/provenance.rs:128`
|
||||
(`ProvenanceStore::verify_integrity`), vs. `search.rs`/`hybrid.rs` (no
|
||||
callers).
|
||||
|
||||
**Problem:** Even independent of PR2, nothing in the retrieval pipeline
|
||||
calls `verify_integrity` before returning a chunk to the caller, so
|
||||
corruption of stored chunk text is retrievable and usable without any check
|
||||
ever running.
|
||||
|
||||
**Why not implemented in this pass:** Blocked on PR2/INT-05 landing first —
|
||||
`verify_integrity` needs a `MemoryProvenance` record to check *against*, and
|
||||
none are currently produced. Tracked as **INT-06**, sequenced after INT-05.
|
||||
@@ -0,0 +1,201 @@
|
||||
# Verification Brief — branch `verify/v3-plus-v6`
|
||||
|
||||
Independent audit of three already-implemented fixes:
|
||||
|
||||
- **P1** — `clawhdf5-ann::hnsw::compute_distance` now delegates to `clawhdf5-accel`'s
|
||||
runtime-dispatched SIMD kernels (`l2_distance`, `cosine_similarity`) instead of
|
||||
scalar loops.
|
||||
- **P2** — `clawhdf5-io::async_read::AsyncFileReader` now opens the file handle
|
||||
once and caches it + its length behind a `tokio::sync::Mutex`.
|
||||
- **PR1** — `clawhdf5-migrate` writes SHINES provenance (`hdf5_writer.rs`) and
|
||||
verifies it on read-back (`validate.rs`).
|
||||
|
||||
Branch state audited: `verify/v3-plus-v6` @ `07b7301` (merge of the v3 ann/io/migrate
|
||||
work and v6 agent/format work). All three areas' existing test suites
|
||||
(`cargo test -p clawhdf5-accel -p clawhdf5-ann -p clawhdf5-io --features async
|
||||
-p clawhdf5-migrate --release`) pass — 41 + 23 + 89 + 26 tests green. That is
|
||||
expected: the defect below is a numerical edge case none of the existing tests
|
||||
exercise.
|
||||
|
||||
---
|
||||
|
||||
## P1 — SIMD distance in `clawhdf5-ann` — DEFECT FOUND
|
||||
|
||||
**File:** `crates/clawhdf5-accel/src/scalar.rs`, `avx2.rs`, `avx512.rs`, `neon.rs`
|
||||
(all four backends share the bug identically; it surfaces in callers through
|
||||
`crates/clawhdf5-ann/src/hnsw.rs:54`, `compute_distance`'s
|
||||
`1.0 - clawhdf5_accel::cosine_similarity(a, b)`).
|
||||
|
||||
**Problem:** The near-zero-norm guard in `cosine_similarity` changed threshold
|
||||
during the SIMD migration, and the new threshold is wrong.
|
||||
|
||||
Old scalar loop (pre-SIMD, `hnsw.rs` @ `55959b4`):
|
||||
|
||||
```rust
|
||||
let denom = norm_a.sqrt() * norm_b.sqrt();
|
||||
if denom < f32::EPSILON {
|
||||
1.0
|
||||
} else {
|
||||
1.0 - (dot / denom)
|
||||
}
|
||||
```
|
||||
|
||||
New code, identical in all four `clawhdf5-accel` backends (e.g.
|
||||
`scalar.rs:23-24`):
|
||||
|
||||
```rust
|
||||
let denom = (norm_a * norm_b).sqrt();
|
||||
if denom == 0.0 { 0.0 } else { dot / denom }
|
||||
```
|
||||
|
||||
The old code clamped *any* near-zero denominator (anything under
|
||||
`f32::EPSILON ≈ 1.19e-7`, not just exact zero) to a safe "maximally
|
||||
dissimilar" result. The new code only special-cases an **exact** `0.0`
|
||||
denominator; anything smaller but nonzero falls through to `dot / denom`.
|
||||
|
||||
For genuinely-zero vectors the two are equivalent (`denom == 0.0` in both, and
|
||||
`1.0 - 0.0 == 1.0` matches the old `1.0`), and the existing test
|
||||
(`hnsw.rs::cosine_zero_vector`, `clawhdf5-accel::test_cosine_zero_vector`)
|
||||
only covers that case — which is why it didn't catch this.
|
||||
|
||||
But for vectors with a small (not exactly zero) norm, the two diverge sharply.
|
||||
Concrete repro (values confirmed via a standalone build of both functions):
|
||||
|
||||
```
|
||||
a = b = [1e-4] // tiny but nonzero, identical vectors
|
||||
old cosine distance = 1.0 // "unreliable direction" fallback, correctly
|
||||
// caps degenerate near-zero vectors at max distance
|
||||
new cosine distance = 0.0 // computed as fully identical
|
||||
```
|
||||
|
||||
`denom` here is `1e-8`, comfortably below `f32::EPSILON` (`1.19e-7`) but not
|
||||
`== 0.0`, so the old guard fired and the new one doesn't. This is not a
|
||||
narrow floating-point-rounding footgun — the divergence spans roughly three
|
||||
orders of magnitude of vector norm (anything with `denom` in
|
||||
`(0, 1.19e-7)`), and it flips the result from "maximally dissimilar" to
|
||||
"identical," the two opposite ends of the distance range. Any HNSW cosine
|
||||
index that indexes or queries a near-zero-magnitude embedding (e.g. an
|
||||
embedder's output for empty/masked/degenerate input, or a soft-deleted/
|
||||
zeroed-out placeholder vector) will silently rank it as a near-duplicate of
|
||||
other near-zero vectors instead of correctly pushing it to the bottom of
|
||||
results.
|
||||
|
||||
Mismatched-length and truly-empty inputs were also checked: empty vectors
|
||||
(`a.len() == b.len() == 0`) behave identically old vs. new (both hit the
|
||||
zero-denominator path → distance `1.0`). Mismatched lengths now panic via
|
||||
`assert_eq!` in every backend, versus the old code's `for i in 0..a.len()`
|
||||
(which panicked on OOB if `b` was shorter, or silently truncated to `a`'s
|
||||
length if `b` was longer). No caller reaches this: `HnswIndex::build_with_metric`
|
||||
and `insert` both assert equal dimensions before any `compute_distance` call,
|
||||
so mismatched lengths are unreachable in practice — not flagging as a
|
||||
separate defect.
|
||||
|
||||
**Proposed fix:** Restore the epsilon-threshold guard in all four
|
||||
`clawhdf5-accel` cosine_similarity backends (`scalar.rs`, `avx2.rs`,
|
||||
`avx512.rs`, `neon.rs`), replacing `if denom == 0.0 { 0.0 }` with
|
||||
`if denom < f32::EPSILON { 0.0 }`, so `1.0 - cosine_similarity(...)` in
|
||||
`hnsw.rs` reproduces the old `denom < f32::EPSILON → 1.0` fallback exactly.
|
||||
Add a regression test in `clawhdf5-accel` (e.g.
|
||||
`test_cosine_near_zero_norm_clamped`) asserting `cosine_similarity(&[1e-4],
|
||||
&[1e-4])` returns `0.0` (so `1.0 - sim == 1.0`, matching the old HNSW
|
||||
fallback) rather than `1.0`, and a matching test in `hnsw.rs`
|
||||
(`cosine_near_zero_vector`, alongside the existing `cosine_zero_vector`) using
|
||||
a tiny-but-nonzero vector pair to lock in `compute_distance == 1.0`.
|
||||
|
||||
TASK: INT-01 — Restore f32::EPSILON near-zero-denom guard in clawhdf5-accel cosine_similarity (all 4 backends) + regression tests
|
||||
|
||||
---
|
||||
|
||||
## P2 — Cached async file handle in `clawhdf5-io` — SOUND, no defect
|
||||
|
||||
**File:** `crates/clawhdf5-io/src/async_read.rs`, `AsyncFileReader::read_at` /
|
||||
`::len` (lines 96-126).
|
||||
|
||||
Checked against the pre-fix version (diff in `b08df7b`, which per-call opened
|
||||
a fresh `tokio::fs::File` and re-stat'd the length):
|
||||
|
||||
- **No seek/read interleaving across tasks.** `read_at` takes
|
||||
`let mut guard = self.handle.lock().await` once at the top and then borrows
|
||||
`file` from that guard (`guard.as_mut()`) for the rest of the function,
|
||||
including both the `seek(...).await` and `read_exact(...).await` calls.
|
||||
Because `file` is a live borrow of `guard`, the Rust borrow checker forces
|
||||
`guard` (and therefore the lock) to stay held across both await points —
|
||||
it cannot be dropped until the whole function returns. `tokio::sync::Mutex`
|
||||
is specifically designed to be held across `.await` (unlike `std::sync::Mutex`),
|
||||
so a second task's `read_at` call blocks at `.lock().await` until the first
|
||||
task's seek+read pair has fully completed. A seek from one task can never be
|
||||
followed by a read from another task on the same descriptor.
|
||||
- **Lazy-init race is also covered by the same lock.** The `if guard.is_none()`
|
||||
open-and-populate branch runs under the same guard acquired at the top, so
|
||||
two concurrent first-callers can't both open+overwrite the cached handle;
|
||||
the second one to acquire the lock sees `guard.is_some()` and reuses it.
|
||||
- **Cached length staleness.** The length is cached forever once populated —
|
||||
intentional and documented in the struct's doc comment ("cached for the
|
||||
lifetime of this reader"). Grepped the whole workspace
|
||||
(`AsyncFileReader` outside `async_read.rs` itself): zero other callers exist
|
||||
yet, so there's no current code path where a caller observes a stale length
|
||||
against a file that changed size mid-lifetime. If the backing file were
|
||||
truncated externally during the reader's life, the stale (larger) cached
|
||||
length would make `read_at` attempt to read more than remains on disk —
|
||||
but that fails loudly via `read_exact`'s `UnexpectedEof` rather than
|
||||
silently returning corrupted/truncated data, which is a safe failure mode,
|
||||
not a correctness bug.
|
||||
- **Short-read/truncation semantics.** The `offset >= file_len → empty`,
|
||||
`to_read = len.min(available)` logic is byte-for-byte unchanged from the
|
||||
pre-fix version; only the source of `file_len` changed (cached vs.
|
||||
freshly stat'd). For the current, only-consumer-is-itself usage pattern
|
||||
(open once, read many times, file not mutated externsally during the
|
||||
reader's life) the observable behavior is identical to before.
|
||||
|
||||
No item raised for P2.
|
||||
|
||||
---
|
||||
|
||||
## PR1 — SHINES provenance in `clawhdf5-migrate` — SOUND, no defect
|
||||
|
||||
**Files:** `crates/clawhdf5-migrate/src/hdf5_writer.rs`,
|
||||
`crates/clawhdf5-migrate/src/main.rs`, `crates/clawhdf5-migrate/src/validate.rs`,
|
||||
`crates/clawhdf5-migrate/src/hdf5_reader.rs`.
|
||||
|
||||
- **Current-run source path / timestamp on `--incremental` merges.**
|
||||
`write_hdf5` (`hdf5_writer.rs:23`) computes `timestamp = iso8601_now()`
|
||||
fresh on every call — it is never read from the merged `data` struct, so
|
||||
the top-level `migrated_at` attribute and the per-dataset
|
||||
`.with_provenance("clawhdf5-migrate", timestamp, source_opt)` calls
|
||||
(`hdf5_writer.rs:147,177,189`) always carry the current run's wall-clock
|
||||
time, incremental or not. For `source_path`: `hdf5_reader::read_hdf5`
|
||||
(used to load the incremental base) explicitly returns
|
||||
`source_path: String::new()` with a comment noting the caller must carry
|
||||
the real path forward (`hdf5_reader.rs:52-56`); `main.rs:160`
|
||||
(`base.source_path = source.source_path`) does exactly that — it
|
||||
overwrites the re-read base's placeholder with the *freshly re-read SQLite
|
||||
source's* path before calling `write_hdf5`, not a previous run's path.
|
||||
Traced through: on an `--incremental` run, both the top-level attributes
|
||||
and every per-dataset provenance attribute reflect the current run, not a
|
||||
stale one. `test_incremental_migration` (`main.rs`) exercises the merge
|
||||
path and passes, though it doesn't assert on `source_path`/`migrated_at`
|
||||
specifically — the coding phase could add that assertion as cheap
|
||||
extra insurance, but it's not fixing a defect, just tightening coverage.
|
||||
- **Hash-mismatch vs. absent-attribute handling.**
|
||||
`verify_chunk_provenance` (`validate.rs:161-184`) returns `Err(...)`
|
||||
(fails loudly, wired through `validate_hdf5`'s `?`) only on
|
||||
`VerifyResult::Mismatch`, i.e. an actual recomputed-vs-stored SHA-256
|
||||
disagreement. `VerifyResult::NoHash` (attribute absent, e.g. an
|
||||
older output file) is handled separately — it sets `all_present = false`
|
||||
and continues, returning `Ok(false)` from `verify_chunk_provenance`
|
||||
(surfaced as `ValidationSummary::provenance_verified == false`, not an
|
||||
error). This is correctly asymmetric: real corruption is a hard error,
|
||||
merely-missing provenance metadata is a soft "unverified" signal, matching
|
||||
the documented contract in the function's doc comment.
|
||||
|
||||
No item raised for PR1.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Item | Verdict | Follow-up |
|
||||
|------|---------|-----------|
|
||||
| P1 SIMD distance | **Defect** — cosine near-zero-norm guard weakened from `< f32::EPSILON` to `== 0.0` across all 4 backends | INT-01 |
|
||||
| P2 async file handle | Sound | none |
|
||||
| PR1 migrate provenance | Sound | none |
|
||||
Reference in New Issue
Block a user