feat: compress fixed-length string datasets (+ fix shared chunk-cache bug)
clawhdf5-agent: fixed-length string datasets (memory text chunks, session summaries, ids, tags, entity/relation names) were stored uncompressed behind a stale "chunked compound not yet supported" comment. Chunked writes work for fixed-size string/compound datatypes like any other, so write_string_dataset now chunks + deflates once a dataset's payload reaches 4 KiB — large, redundant NullPad content compresses well while tiny metadata stays contiguous (no chunk-overhead bloat). The dead `compress` parameter is removed in favor of this size heuristic. clawhdf5-format: enabling string compression exposed a latent bug — the per-file ChunkCache built its chunk index once and reused it for every chunked dataset in the file, keyed only by chunk coordinate with no dataset discrimination. With one chunked dataset per file this never surfaced; with two of different rank (a 1-D compressed string array and the 2-D embeddings matrix) the first dataset's rank-1 index was reused for the second, panicking with an out-of-bounds chunk coordinate. The cache now binds to a dataset by its chunk-index address and rebinds — dropping the index, chunk-index map, layout, and decompressed slots — whenever the dataset being read changes, while still caching repeated/sequential access to the same dataset. Tests: facade regression reading a 1-D compressed string dataset and a 2-D compressed f32 dataset through one shared File cache (verified to panic without the fix); existing agent e2e tests (large text chunks, migration round-trip) now pass with compression on. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -65,7 +65,7 @@ fn build_memory_group(
|
||||
let mut group = builder.create_group("memory");
|
||||
|
||||
// chunks: fixed-length string array
|
||||
write_string_dataset(&mut group, "chunks", &cache.chunks, false);
|
||||
write_string_dataset(&mut group, "chunks", &cache.chunks);
|
||||
|
||||
// embeddings: f32 [N x D]
|
||||
let n = cache.embeddings.len() as u64;
|
||||
@@ -101,7 +101,7 @@ fn build_memory_group(
|
||||
}
|
||||
|
||||
// source_channel: fixed-length string array
|
||||
write_string_dataset(&mut group, "source_channel", &cache.source_channels, false);
|
||||
write_string_dataset(&mut group, "source_channel", &cache.source_channels);
|
||||
|
||||
// timestamps: f64 array
|
||||
group
|
||||
@@ -109,11 +109,11 @@ fn build_memory_group(
|
||||
.with_f64_data(&cache.timestamps)
|
||||
.fill_time(FillTime::Never);
|
||||
|
||||
// session_ids: fixed-length string array (no compression — chunked compound not yet supported)
|
||||
write_string_dataset(&mut group, "session_ids", &cache.session_ids, false);
|
||||
// session_ids: fixed-length string array (auto-compressed when large)
|
||||
write_string_dataset(&mut group, "session_ids", &cache.session_ids);
|
||||
|
||||
// tags: fixed-length string array (no compression — chunked compound not yet supported)
|
||||
write_string_dataset(&mut group, "tags", &cache.tags, false);
|
||||
// tags: fixed-length string array (auto-compressed when large)
|
||||
write_string_dataset(&mut group, "tags", &cache.tags);
|
||||
|
||||
// tombstones: u8 array — use compact if small
|
||||
{
|
||||
@@ -150,7 +150,7 @@ fn build_sessions_group(
|
||||
let mut group = builder.create_group("sessions");
|
||||
|
||||
let ids: Vec<String> = sessions.entries.iter().map(|e| e.id.clone()).collect();
|
||||
write_string_dataset(&mut group, "ids", &ids, false);
|
||||
write_string_dataset(&mut group, "ids", &ids);
|
||||
|
||||
let start_idxs: Vec<i64> = sessions
|
||||
.entries
|
||||
@@ -165,14 +165,14 @@ fn build_sessions_group(
|
||||
group.create_dataset("end_idxs").with_i64_data(&end_idxs);
|
||||
|
||||
let channels: Vec<String> = sessions.entries.iter().map(|e| e.channel.clone()).collect();
|
||||
write_string_dataset(&mut group, "channels", &channels, false);
|
||||
write_string_dataset(&mut group, "channels", &channels);
|
||||
|
||||
let timestamps: Vec<f64> = sessions.entries.iter().map(|e| e.ts).collect();
|
||||
group
|
||||
.create_dataset("timestamps")
|
||||
.with_f64_data(×tamps);
|
||||
|
||||
write_string_dataset(&mut group, "summaries", &sessions.summaries, false);
|
||||
write_string_dataset(&mut group, "summaries", &sessions.summaries);
|
||||
|
||||
let finished = group.finish();
|
||||
builder.add_group(finished);
|
||||
@@ -192,14 +192,14 @@ fn build_knowledge_group(
|
||||
.with_i64_data(&entity_ids);
|
||||
|
||||
let entity_names: Vec<String> = knowledge.entities.iter().map(|e| e.name.clone()).collect();
|
||||
write_string_dataset(&mut group, "entity_names", &entity_names, false);
|
||||
write_string_dataset(&mut group, "entity_names", &entity_names);
|
||||
|
||||
let entity_types: Vec<String> = knowledge
|
||||
.entities
|
||||
.iter()
|
||||
.map(|e| e.entity_type.clone())
|
||||
.collect();
|
||||
write_string_dataset(&mut group, "entity_types", &entity_types, false);
|
||||
write_string_dataset(&mut group, "entity_types", &entity_types);
|
||||
|
||||
let emb_idxs: Vec<i64> = knowledge.entities.iter().map(|e| e.embedding_idx).collect();
|
||||
group
|
||||
@@ -222,7 +222,7 @@ fn build_knowledge_group(
|
||||
.iter()
|
||||
.map(|r| r.relation.clone())
|
||||
.collect();
|
||||
write_string_dataset(&mut group, "relation_types", &rel_types, false);
|
||||
write_string_dataset(&mut group, "relation_types", &rel_types);
|
||||
|
||||
let rel_weights: Vec<f32> = knowledge.relations.iter().map(|r| r.weight).collect();
|
||||
group
|
||||
@@ -234,7 +234,7 @@ fn build_knowledge_group(
|
||||
|
||||
// Aliases
|
||||
if !knowledge.alias_strings.is_empty() {
|
||||
write_string_dataset(&mut group, "alias_strings", &knowledge.alias_strings, false);
|
||||
write_string_dataset(&mut group, "alias_strings", &knowledge.alias_strings);
|
||||
group
|
||||
.create_dataset("alias_entity_ids")
|
||||
.with_i64_data(&knowledge.alias_entity_ids);
|
||||
@@ -252,11 +252,15 @@ fn build_knowledge_group(
|
||||
///
|
||||
/// When `compress` is true, uses chunked storage with deflate(6) —
|
||||
/// NullPad strings have high redundancy and compress very well.
|
||||
/// Payload size (bytes) at or above which a fixed-length string dataset is
|
||||
/// stored chunked + deflate-compressed. Below this, the chunk B-tree/heap
|
||||
/// overhead outweighs the savings, so the data is left contiguous.
|
||||
const STRING_COMPRESS_THRESHOLD: usize = 4096;
|
||||
|
||||
fn write_string_dataset(
|
||||
group: &mut clawhdf5_format::type_builders::GroupBuilder,
|
||||
name: &str,
|
||||
strings: &[String],
|
||||
compress: bool,
|
||||
) {
|
||||
if strings.is_empty() {
|
||||
// Empty dataset: use 1-byte string type with no data
|
||||
@@ -278,6 +282,7 @@ fn write_string_dataset(
|
||||
bytes.resize(max_len, 0);
|
||||
raw.extend_from_slice(&bytes);
|
||||
}
|
||||
let raw_len = raw.len();
|
||||
|
||||
let dtype = Datatype::String {
|
||||
size: max_len as u32,
|
||||
@@ -288,9 +293,12 @@ fn write_string_dataset(
|
||||
.create_dataset(name)
|
||||
.with_compound_data(dtype, raw, strings.len() as u64);
|
||||
|
||||
// Deflate compression for string datasets — NullPad has high redundancy
|
||||
if compress && strings.len() > 1 {
|
||||
// Chunk size: target ~64KB chunks for string data
|
||||
// Fixed-length NullPad strings have high redundancy (padding + repeated
|
||||
// content), so deflate pays off once the payload is large enough to absorb
|
||||
// the chunking overhead. Fixed-length string datasets are chunkable like
|
||||
// any other fixed-size datatype.
|
||||
if strings.len() > 1 && raw_len >= STRING_COMPRESS_THRESHOLD {
|
||||
// Target ~64KB chunks for string data.
|
||||
let elem_size = max_len as u64;
|
||||
let target_chunk = 64 * 1024;
|
||||
let rows_per_chunk = (target_chunk / elem_size).max(1).min(strings.len() as u64);
|
||||
|
||||
Reference in New Issue
Block a user