Makes the README's "cryptographically verifiable memory" true. With HDF5Memory::set_signing_key(key), every checkpoint stores a signed manifest of the store: a SHA-256 per memory record (text, embedding as stored, channel, timestamp, session, tags, deleted flag, activation) in a Merkle tree, plus hashes of the settings (and WAL mark), sessions and knowledge graph. The signature, public key and manifest hashes go in /meta; the per-record hashes in /integrity/record_hashes, so HDF5Memory::verify(path, &public_key) can say which records changed, not just that something did. A forged manifest fails the signature. Decisions, as agreed: - the key is set on the open store and never persisted; - a signed store refuses to checkpoint without its key (MemoryError::SigningKeyRequired); remove_signature() is the deliberate way back to unsigned; - checkpoints only: saves still in the WAL are not covered, and verify reports how many there are. The hashes cover exactly what the file persists, in the form the loader returns it (strings lose trailing NULs; an empty WAL mark is not written), so untouched stores verify across any number of reopen and checkpoint cycles. MemoryError becomes #[non_exhaustive] (it already gains variants in this unreleased version). CLI: keygen (owner-only key file), --signing-key / CLAWHDF5_SIGNING_KEY on writing commands (create signs immediately), verify --public-key (JSON; exit 2 if not valid), `signed` in create/stats output. Tests: reopen/checkpoint cycles with awkward strings (f16 and f32), refusal without the key, wrong and rotated keys, eight kinds of edit each detected and located, a forged manifest, unsigned stores, NULs in text, and an edit made in place with h5py that verify pinpoints. Cost on tank (search_harness --signing-study --full, 3 runs): ~20% of a checkpoint (+9 ms at 10K, +89-112 ms at 100K), verify 18.6 ms / 247 ms, 32 bytes per record in the file. New deps ed25519-dalek, sha2, rand_core: pure Rust, the no-C check passes. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
156 lines
5.0 KiB
Rust
156 lines
5.0 KiB
Rust
//! An agent store is a standard HDF5 file: h5py can open it and read every
|
|
//! dataset.
|
|
//!
|
|
//! It could not: the float datatype's sign-bit position was hard-coded for
|
|
//! f64, so every f32 dataset (embeddings, norms, activation weights) made
|
|
//! libhdf5 refuse the file with "sign bit position out of bounds".
|
|
|
|
use std::process::Command;
|
|
|
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
|
|
|
fn python() -> String {
|
|
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
|
}
|
|
|
|
fn h5py_available() -> bool {
|
|
Command::new(python())
|
|
.args(["-c", "import h5py"])
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[test]
|
|
fn h5py_reads_every_dataset_of_an_agent_store() {
|
|
if !h5py_available() {
|
|
assert!(
|
|
std::env::var("CLAWHDF5_REQUIRE_INTEROP").as_deref() != Ok("1"),
|
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
|
);
|
|
eprintln!("SKIP: python3 with h5py not available");
|
|
return;
|
|
}
|
|
let dir = tempfile::tempdir().unwrap();
|
|
for float16 in [false, true] {
|
|
let path = dir.path().join(format!("store_{float16}.h5"));
|
|
let mut cfg = MemoryConfig::new(path.clone(), "agent", 8);
|
|
cfg.float16 = float16;
|
|
let mut m = HDF5Memory::create(cfg).unwrap();
|
|
// save_batch checkpoints, so the records are in the .h5, not the WAL.
|
|
m.save_batch(
|
|
(0..20)
|
|
.map(|i| MemoryEntry {
|
|
chunk: format!("memory {i}"),
|
|
embedding: (0..8).map(|j| ((i * 8 + j) as f32).sin()).collect(),
|
|
source_channel: "test".into(),
|
|
timestamp: i as f64,
|
|
session_id: "s".into(),
|
|
tags: String::new(),
|
|
})
|
|
.collect(),
|
|
)
|
|
.unwrap();
|
|
drop(m);
|
|
|
|
// Exact expected values, as bits: numpy's sin need not match Rust's
|
|
// to the last place.
|
|
let bits = (0..160)
|
|
.map(|k| (k as f32).sin().to_bits().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let script = format!(
|
|
r#"
|
|
import h5py, numpy as np
|
|
want = np.float16 if {py_bool} else np.float32
|
|
with h5py.File("{path}", "r") as f:
|
|
names = []
|
|
f.visititems(lambda n, o: names.append(n) if isinstance(o, h5py.Dataset) else None)
|
|
for n in names:
|
|
f[n][()] # every dataset must decode
|
|
e = f["memory/embeddings"]
|
|
assert e.dtype == want, e.dtype
|
|
assert e.shape == (20, 8), e.shape
|
|
ref = np.array([{bits}], dtype=np.uint32).view(np.float32).astype(want).reshape(20, 8)
|
|
assert (e[()] == ref).all()
|
|
assert f["memory/norms"].dtype == np.float32
|
|
print(len(names))
|
|
"#,
|
|
py_bool = if float16 { "True" } else { "False" },
|
|
path = path.display()
|
|
);
|
|
let out = Command::new(python())
|
|
.args(["-c", &script])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
out.status.success(),
|
|
"float16={float16}: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
let n: usize = String::from_utf8_lossy(&out.stdout).trim().parse().unwrap();
|
|
assert!(n >= 10, "only {n} datasets");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_edit_made_with_h5py_breaks_the_signature_and_names_the_record() {
|
|
if !h5py_available() {
|
|
assert!(
|
|
std::env::var("CLAWHDF5_REQUIRE_INTEROP").as_deref() != Ok("1"),
|
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
|
);
|
|
eprintln!("SKIP: python3 with h5py not available");
|
|
return;
|
|
}
|
|
use clawhdf5_agent::signing::SigningKey;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("signed.h5");
|
|
let key = SigningKey::from_bytes(&[42; 32]);
|
|
let mut m = HDF5Memory::create(MemoryConfig::new(path.clone(), "agent", 8)).unwrap();
|
|
m.set_signing_key(key.clone());
|
|
m.save_batch(
|
|
(0..10)
|
|
.map(|i| MemoryEntry {
|
|
chunk: format!("memory {i}"),
|
|
embedding: (0..8).map(|j| ((i * 8 + j) as f32).cos()).collect(),
|
|
source_channel: "test".into(),
|
|
timestamp: i as f64,
|
|
session_id: "s".into(),
|
|
tags: String::new(),
|
|
})
|
|
.collect(),
|
|
)
|
|
.unwrap();
|
|
drop(m);
|
|
assert!(
|
|
HDF5Memory::verify(&path, &key.verifying_key())
|
|
.unwrap()
|
|
.is_valid()
|
|
);
|
|
|
|
// Someone edits one timestamp in place with h5py.
|
|
let script = format!(
|
|
r#"
|
|
import h5py
|
|
with h5py.File("{}", "r+") as f:
|
|
ts = f["memory/timestamps"]
|
|
ts[3] = 12345.0
|
|
"#,
|
|
path.display()
|
|
);
|
|
let out = Command::new(python())
|
|
.args(["-c", &script])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
out.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
|
|
let r = HDF5Memory::verify(&path, &key.verifying_key()).unwrap();
|
|
assert!(r.signature_valid && !r.is_valid(), "{r:?}");
|
|
assert_eq!(r.changed_records, vec![3]);
|
|
}
|