Compare commits

...
Author SHA1 Message Date
osobhandClaude Fable 5.1 2053b69f07 chore(release): v2.2.0, point repository URLs at git.redclaw.dev
Bump all workspace crates, the node package and pyproject to 2.2.0 and
finalize the changelog. The repository URL in every manifest pointed at a
GitHub location that does not resolve; use the real origin.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-18 20:58:29 -07:00
osobhandClaude Fable 5.1 b55b7dbac5 fix(format): parse HDF5 2.0 native complex datatypes (class 11)
Class 11 (datatype version 5) properties are a single base floating-point
datatype message, not a compound-style member list. The old parser read the
base type's bytes as member names, yielding a garbage datatype, and failed
with UnexpectedEof when a complex type was nested in a compound.

Parse the base type and surface the type as the equivalent {r, i} compound
(the shape h5py writes for numpy complex dtypes), with a size check against
the base type. Covered by byte-level tests taken from HDF5 2.0 output and an
h5py end-to-end test (writer_h5py_tests is now 27/27 against HDF5 2.0.0).

Found while validating a user report of InvalidDatatypeVersion
{ class: 6, version: 5 } against v2.1.0 (already fixed on main in a13ff51,
never released). Add docs/known-issues.md recording that report, this bug,
the open reference-v4 gap and a gpu_tests parallel-run hang; credit the
reporter in the changelog.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-18 20:58:29 -07:00
osobh 48c745a960 Merge PR #2: performance, security and provenance hardening + two audit fixes
CI / test (push) Canceled after 0s
ann/io/migrate/agent work from three agent missions, an independent audit, and the two defects it found: the cosine near-zero guard weakened during the SIMD migration, and WAL appends after a torn tail being silently unreplayable. 52 suites green; both fixes proven by negative control.
2026-08-17 14:22:13 +00:00
clawhdf5 committer agentandClaude Sonnet 5 377c8b6f17 fix(accel): restore f32::EPSILON near-zero-denom guard in cosine_similarity
CI / test (pull_request) Canceled after 0s
The SIMD migration weakened the near-zero-norm guard in all four
clawhdf5-accel cosine_similarity backends (scalar/avx2/avx512/neon)
from `denom < f32::EPSILON` to `denom == 0.0`. Vectors with a tiny
but nonzero norm (denom in (0, 1.19e-7)) fell through to dot/denom
and scored as identical instead of maximally dissimilar, diverging
from the pre-SIMD scalar loop's documented fallback behavior.

Restores the epsilon threshold in all four backends so
`1.0 - cosine_similarity(...)` in hnsw.rs::compute_distance
reproduces the old fallback exactly. Adds regression tests in
clawhdf5-accel and clawhdf5-ann locking in the near-zero-norm case.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-17 13:28:43 +00:00
Omar SobhandClaude Opus 5 07b7301ded merge: combine the v3 (ann/io/migrate) and v6 (agent/format) mission work
Two missions found largely disjoint ground — v3 in clawhdf5-ann, -io and
-migrate, v6 in -agent and -format — so this merge is mostly additive.

One genuine collision: BOTH runs independently implemented
`Dataset::verify_provenance` in clawhdf5/src/reader.rs, and git kept both,
producing `E0592 duplicate definitions`. They were functionally identical
apart from `self.file.data.as_bytes()` (v3) vs `self.file.as_bytes()` (v6).
Kept v6's — it is the variant that compiles against the current tree and
passed 52 suites, and its doc comment is the more honest one, stating both the
full-read cost and that an unkeyed hash stored beside its data is not a
tamper-evidence guarantee.

Verified present after the merge:
  P1  clawhdf5-ann now depends on clawhdf5-accel; compute_distance calls
      l2_distance / cosine_similarity instead of a scalar loop
  P2  AsyncFileReader caches its handle and length behind a mutex
  PR1 clawhdf5-migrate writes SHINES provenance, and validate checks it
  v6  WAL torn-tail truncation, char-boundary truncate, agent hardening

52 suites pass, 0 failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 06:19:23 -07:00
Omar Sobh d3c65ccb58 Merge remote-tracking branch 'origin/clawmates/mission-01a00c41-421200ee' into verify/v3-plus-v6
# Conflicts:
#	crates/clawhdf5/Cargo.toml
2026-08-17 06:15:19 -07:00
Omar Sobh b08df7b628 clawmates: phase work
Mission: 01a00c41-bac0-7eb3-a8c8-8b7044f3086d
Phase: 01a00c41-bac2-71e3-a58b-c473421200ee

Committed by the ClawMates delivery pipeline from the agents' working tree. Authored by agents, not by the named committer.
2026-08-16 20:44:28 +00:00
41 changed files with 933 additions and 121 deletions
+11 -1
View File
@@ -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
View File
@@ -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"
+2 -2
View File
@@ -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"]
+1 -1
View File
@@ -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 }
}
}
+1 -1
View File
@@ -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 }
}
}
+12
View File
@@ -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();
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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)]) {
+8 -8
View File
@@ -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 -1
View File
@@ -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"
+5 -4
View File
@@ -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]
+18 -24
View File
@@ -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 -1
View File
@@ -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"
+3 -3
View File
@@ -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 }
+2 -2
View File
@@ -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"]
+2 -2
View File
@@ -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"]
+3 -3
View File
@@ -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"
+100 -19
View File
@@ -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() {
+2 -2
View File
@@ -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"]
+3 -3
View File
@@ -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 }
+24 -8
View File
@@ -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)
}
}
+5 -5
View File
@@ -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(),
})
}
+91 -5
View File
@@ -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, &timestamp);
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'));
}
}
+9
View File
@@ -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(),
})
}
+47
View File
@@ -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()
}
+3 -3
View File
@@ -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"
+4 -4
View File
@@ -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 }
+4 -4
View File
@@ -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"
+1 -1
View File
@@ -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" }
+7 -7
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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)
---
+82
View File
@@ -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 14. 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 35 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 24 (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.
+2 -2
View File
@@ -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",
+55
View File
@@ -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.
+42
View File
@@ -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.
+97
View File
@@ -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.
+201
View File
@@ -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 |