The setting was persisted in /meta and otherwise ignored: embeddings
were always written as f32. It now does what it says.
clawhdf5-format:
- `DatasetBuilder::with_f16_data` writes IEEE binary16 (numpy float16),
rounding to nearest-even, and `make_f16_type`.
- `clawhdf5_format::float16` holds the f32 <-> f16 conversions, the one
implementation the writer, the reader and the agent all use. Checked
against the `half` crate on 16.7M f32 values and round-trips all 65536
half values; the h5py interop tests confirm the rounding matches
numpy's bit for bit (4020 values incl. ties, subnormals, overflow).
- Reading little-endian float16 as f32 has a fast path.
clawhdf5-agent:
- A float16 store writes /memory/embeddings as half precision, and
`MemoryCache::half_precision` rounds each embedding as it enters the
cache (save, update, WAL replay, and on load of a store still f32 on
disk), so memory and file agree bit for bit and a store searches the
same before and after a reopen (tested).
- Values beyond +-65504 are refused with the new
`MemoryError::InvalidEntry` rather than stored as infinity, on every
save path; batches are all or nothing, and a rejected ephemeral entry
stays in the ephemeral tier. Breaking for exhaustive matches.
- CLI: `create --float16`. Off by default.
Measured on tank, 384-dim, six runs alternating order, medians
(search_harness --float16-study --full): at 100K the file goes from
154.0 to 80.8 MiB (-48%), checkpoint 752 -> 512 ms, open 300 -> 252 ms;
vector recall@10 against an exact scan and hybrid_search latency do not
change. At 10K open is 3 ms slower. Also a test that h5py opens a whole
agent store, f32 and float16, and decodes every dataset.
Docs: README, BENCHMARKS.md ("float16 embedding storage"), CHANGELOG
(including the h5py interop fixes in the previous commit), CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
95 lines
3.2 KiB
Rust
95 lines
3.2 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");
|
|
}
|
|
}
|