use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::ops::Range;
use std::path::{Path, PathBuf};
use crate::error::{HoronError, HoronResult};
use crate::format::{
EPOCH_FLAG_RESTAMP, EPOCH_FLAG_SPECULATIVE, FLAG_MEANING_ADDRESSED, HEADER_SIZE,
HIST_FLAG_GACL, HIST_FLAG_QUANTIZED, HIST_HEADER_SIZE, HIST_MAGIC, HIST_VERSION,
HIST_VERSION_QUANTIZED, MAX_HIST_BYTES, DIM_USER_DEFINED_START,
};
use crate::header::GeoHeader;
use crate::quant::SemLayout;
use crate::wal::{self, WalEntry, WalPayload};
pub(crate) fn segment_path(base: &Path, n: u32) -> PathBuf {
PathBuf::from(format!("{}.h{:06}", base.display(), n))
}
pub(crate) fn list_segments(base: &Path) -> Vec<(u32, PathBuf)> {
let Some(dir) = base.parent() else { return Vec::new() };
let Some(stem) = base.file_name().and_then(|s| s.to_str()) else { return Vec::new() };
let prefix = format!("{}.h", stem);
let mut out = Vec::new();
let Ok(read) = fs::read_dir(if dir.as_os_str().is_empty() { Path::new(".") } else { dir })
else {
return Vec::new();
};
for entry in read.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if let Some(suffix) = name.strip_prefix(&prefix) {
if suffix.len() == 6 && suffix.bytes().all(|b| b.is_ascii_digit()) {
if let Ok(n) = suffix.parse::<u32>() {
out.push((n, entry.path()));
}
}
}
}
out.sort_by_key(|(n, _)| *n);
out
}
pub(crate) fn next_segment_number(base: &Path) -> u32 {
list_segments(base).last().map_or(1, |(n, _)| n + 1)
}
pub(crate) fn write_segment(
base: &Path,
n: u32,
layout: SemLayout,
first_seq: u32,
end_seq: u32,
entries: &[WalEntry],
) -> HoronResult<PathBuf> {
let final_path = segment_path(base, n);
let tmp_path = PathBuf::from(format!("{}.tmp", final_path.display()));
let mut raw = Vec::new();
for e in entries {
e.write_to(&mut raw, &layout)?;
}
let compressed = zstd::bulk::compress(&raw, 3)
.map_err(|e| HoronError::CompressionError(e.to_string()))?;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)?;
file.write_all(&HIST_MAGIC)?;
let (version, layout_flags) = if layout.quantized {
let mut f = HIST_FLAG_QUANTIZED;
if layout.gacl {
f |= HIST_FLAG_GACL;
}
(HIST_VERSION_QUANTIZED, f)
} else {
(HIST_VERSION, 0)
};
file.write_all(&[version, layout.dims as u8, layout_flags, 0])?;
file.write_all(&first_seq.to_le_bytes())?;
file.write_all(&end_seq.to_le_bytes())?;
file.write_all(&(raw.len() as u32).to_le_bytes())?;
file.write_all(&(compressed.len() as u32).to_le_bytes())?;
file.write_all(&compressed)?;
file.write_all(&crc32fast::hash(&raw).to_le_bytes())?;
file.sync_all()?;
fs::rename(&tmp_path, &final_path)?;
Ok(final_path)
}
pub(crate) fn cleanup_orphan_segment_tmp(base: &Path) {
let Some(dir) = base.parent() else { return };
let Some(stem) = base.file_name().and_then(|s| s.to_str()) else { return };
let prefix = format!("{}.h", stem);
let Ok(read) = fs::read_dir(if dir.as_os_str().is_empty() { Path::new(".") } else { dir })
else {
return;
};
for entry in read.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.starts_with(&prefix) && name.ends_with(".tmp") {
let _ = fs::remove_file(entry.path());
log::warn!("removed orphaned history segment tempfile {}", name);
}
}
}
fn read_segment(path: &Path, expected: SemLayout) -> HoronResult<(u32, u32, Vec<WalEntry>)> {
let mut file = File::open(path)?;
let mut header = [0u8; HIST_HEADER_SIZE];
file.read_exact(&mut header)?;
if header[0..4] != HIST_MAGIC {
return Err(HoronError::InvalidFormat(format!(
"{}: not a history segment (bad magic)",
path.display()
)));
}
let layout = match header[4] {
HIST_VERSION => SemLayout::plain(header[5] as usize),
HIST_VERSION_QUANTIZED => SemLayout {
dims: header[5] as usize,
quantized: header[6] & HIST_FLAG_QUANTIZED != 0,
gacl: header[6] & HIST_FLAG_GACL != 0,
},
v => {
return Err(HoronError::InvalidFormat(format!(
"{}: unsupported history segment version {}",
path.display(),
v
)))
}
};
if layout != expected {
return Err(HoronError::InvalidFormat(format!(
"{}: segment layout {:?} does not match main file's {:?}",
path.display(),
layout,
expected
)));
}
let first_seq = u32::from_le_bytes(header[8..12].try_into().unwrap());
let end_seq = u32::from_le_bytes(header[12..16].try_into().unwrap());
let raw_len = u32::from_le_bytes(header[16..20].try_into().unwrap()) as usize;
let comp_len = u32::from_le_bytes(header[20..24].try_into().unwrap()) as usize;
if raw_len > MAX_HIST_BYTES || comp_len > MAX_HIST_BYTES {
return Err(HoronError::InvalidFormat(format!(
"{}: segment length field exceeds maximum — corrupt header",
path.display()
)));
}
let compressed = crate::format::read_bounded_vec(&mut file, comp_len, "history segment")?;
let raw = zstd::bulk::decompress(&compressed, raw_len)
.map_err(|e| HoronError::CompressionError(e.to_string()))?;
let mut crc_buf = [0u8; 4];
file.read_exact(&mut crc_buf)?;
let stored = u32::from_le_bytes(crc_buf);
let computed = crc32fast::hash(&raw);
if stored != computed {
return Err(HoronError::ChecksumMismatch {
expected: stored,
actual: computed,
context: format!("history segment {}", path.display()),
});
}
let mut entries = Vec::new();
let mut cursor = std::io::Cursor::new(&raw);
while let Some(entry) = WalEntry::read_from(&mut cursor, &layout)? {
entries.push(entry);
}
Ok((first_seq, end_seq, entries))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EpochInfo {
pub id: u64,
pub seq: u32,
pub speculative: bool,
}
#[derive(Debug, Clone, Default)]
pub struct StateNode {
pub data: Vec<u8>,
pub metadata: Vec<(String, String)>,
pub semantic: Vec<u8>,
}
#[derive(Debug, Default)]
pub struct HoronStateView {
nodes: BTreeMap<String, StateNode>,
}
impl HoronStateView {
pub fn get(&self, key: &str) -> Option<&StateNode> {
self.nodes.get(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.nodes.keys().map(|k| k.as_str())
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct KeyDelta {
pub key: String,
pub kind: DeltaKind,
}
#[derive(Debug, Clone)]
pub enum DeltaKind {
Added,
Removed,
Moved {
displacement: Vec<f64>,
distance: f64,
},
}
pub struct HoronHistory {
entries: Vec<WalEntry>,
epochs: Vec<EpochInfo>,
semantic_dims: usize,
complete: bool,
}
impl HoronHistory {
pub fn open<P: AsRef<Path>>(path: P) -> HoronResult<Self> {
let path = path.as_ref();
let mut file = File::open(path)?;
let mut hbuf = [0u8; HEADER_SIZE];
file.read_exact(&mut hbuf)?;
let header = GeoHeader::from_bytes(&hbuf)?;
let semantic_dims = header.semantic_dims as usize;
let layout = SemLayout::from_header(&header);
if header.flags & FLAG_MEANING_ADDRESSED != 0 {
let bounds_len = semantic_dims.saturating_sub(DIM_USER_DEFINED_START) * 16;
file.seek(SeekFrom::Current(bounds_len as i64))?;
}
let mut buf4 = [0u8; 4];
file.read_exact(&mut buf4)?;
let snap_byte_len = u32::from_le_bytes(buf4) as u64;
file.read_exact(&mut buf4)?;
let node_count = u32::from_le_bytes(buf4);
if node_count > 0 && header.compression_enabled() {
file.read_exact(&mut buf4)?;
let comp_len = u32::from_le_bytes(buf4) as u64;
file.seek(SeekFrom::Current(comp_len as i64))?;
} else {
file.seek(SeekFrom::Current(snap_byte_len as i64))?;
}
if header.version >= 2 {
file.seek(SeekFrom::Current(4))?; }
let (_count, _base_seq) = wal::read_wal_header(&mut file)?;
let mut merged: BTreeMap<u32, WalEntry> = BTreeMap::new();
if header.wal_compressed() {
let algo = header.compression_algo();
while let Ok(Some((block, n))) = wal::read_wal_block(&mut file, algo) {
let mut cursor = std::io::Cursor::new(&block);
for _ in 0..n {
match WalEntry::read_from(&mut cursor, &layout) {
Ok(Some(e)) => {
merged.entry(e.seq).or_insert(e);
}
_ => break,
}
}
}
} else {
while let Ok(Some(e)) = WalEntry::read_from(&mut file, &layout) {
merged.entry(e.seq).or_insert(e);
}
}
for (_n, seg_path) in list_segments(path) {
let (_first, _end, entries) = read_segment(&seg_path, layout)?;
for e in entries {
merged.entry(e.seq).or_insert(e);
}
}
let complete = merged.keys().next().is_none_or(|&first| first == 1);
let entries: Vec<WalEntry> = merged.into_values().collect();
let mut epochs: Vec<EpochInfo> = Vec::new();
for e in &entries {
if let WalPayload::Epoch { epoch_id, flags } = &e.payload {
if flags & EPOCH_FLAG_RESTAMP != 0 {
continue;
}
if !epochs.iter().any(|info| info.id == *epoch_id) {
epochs.push(EpochInfo {
id: *epoch_id,
seq: e.seq,
speculative: flags & EPOCH_FLAG_SPECULATIVE != 0,
});
}
}
}
epochs.sort_by_key(|i| i.id);
Ok(Self { entries, epochs, semantic_dims, complete })
}
pub fn is_complete(&self) -> bool {
self.complete
}
pub fn epochs(&self) -> &[EpochInfo] {
&self.epochs
}
pub fn semantic_dims(&self) -> usize {
self.semantic_dims
}
pub fn as_of(&self, epoch_id: u64) -> HoronResult<HoronStateView> {
let info = self.epoch_info(epoch_id)?;
if !self.complete {
return Err(HoronError::InvalidOperation(
"history is incomplete (missing sidecar segments or retention \
enabled after writes) — as_of cannot reconstruct exact state"
.to_string(),
));
}
let mut view = HoronStateView::default();
for e in &self.entries {
if e.seq > info.seq {
break;
}
Self::apply(&mut view, e);
}
Ok(view)
}
pub fn trajectory(&self, key: &str, dim_range: &Range<usize>) -> Vec<(u64, Vec<f64>)> {
let mut out = Vec::new();
let mut current: Option<Vec<u8>> = None;
let mut exists = false;
let mut seals = self
.epochs
.iter()
.filter(|i| !i.speculative)
.peekable();
for e in &self.entries {
while let Some(info) = seals.peek() {
if e.seq > info.seq {
if exists {
if let Some(coords) = ¤t {
out.push((info.id, decode_range(coords, dim_range)));
}
}
seals.next();
} else {
break;
}
}
if e.key == key {
match &e.payload {
WalPayload::Insert(node) => {
exists = true;
if !node.semantic_coords.is_empty()
&& node.semantic_coords.iter().any(|&b| b != 0)
{
current = Some(node.semantic_coords.clone());
}
}
WalPayload::Update { .. } => exists = true,
WalPayload::Delete => {
exists = false;
current = None;
}
WalPayload::SetSemantic { coords } => current = Some(coords.clone()),
_ => {}
}
}
}
for info in seals {
if exists {
if let Some(coords) = ¤t {
out.push((info.id, decode_range(coords, dim_range)));
}
}
}
out
}
pub fn delta(
&self,
epoch_a: u64,
epoch_b: u64,
dim_range: &Range<usize>,
) -> HoronResult<Vec<KeyDelta>> {
let a = self.as_of(epoch_a)?;
let b = self.as_of(epoch_b)?;
let mut out = Vec::new();
for (key, node_b) in &b.nodes {
match a.nodes.get(key) {
None => out.push(KeyDelta { key: key.clone(), kind: DeltaKind::Added }),
Some(node_a) => {
let ca = decode_range(&node_a.semantic, dim_range);
let cb = decode_range(&node_b.semantic, dim_range);
let displacement: Vec<f64> =
ca.iter().zip(&cb).map(|(x, y)| y - x).collect();
let distance =
displacement.iter().map(|d| d * d).sum::<f64>().sqrt();
if distance > 0.0 {
out.push(KeyDelta {
key: key.clone(),
kind: DeltaKind::Moved { displacement, distance },
});
}
}
}
}
for key in a.nodes.keys() {
if !b.nodes.contains_key(key) {
out.push(KeyDelta { key: key.clone(), kind: DeltaKind::Removed });
}
}
Ok(out)
}
fn epoch_info(&self, epoch_id: u64) -> HoronResult<EpochInfo> {
self.epochs
.iter()
.find(|i| i.id == epoch_id)
.copied()
.ok_or_else(|| {
HoronError::InvalidOperation(format!("unknown epoch {}", epoch_id))
})
}
fn apply(view: &mut HoronStateView, e: &WalEntry) {
match &e.payload {
WalPayload::Insert(node) => {
let s = view.nodes.entry(e.key.clone()).or_default();
s.data = node.data.clone();
s.metadata = node.metadata.clone();
if !node.semantic_coords.is_empty()
&& node.semantic_coords.iter().any(|&b| b != 0)
{
s.semantic = node.semantic_coords.clone();
}
}
WalPayload::Update { data, metadata } => {
let s = view.nodes.entry(e.key.clone()).or_default();
s.data = data.clone();
for (mk, mv) in metadata {
s.metadata.push((mk.clone(), mv.clone()));
}
}
WalPayload::Delete => {
view.nodes.remove(&e.key);
}
WalPayload::SetMeta { meta_key, meta_value } => {
if let Some(s) = view.nodes.get_mut(&e.key) {
s.metadata.push((meta_key.clone(), meta_value.clone()));
}
}
WalPayload::SetSemantic { coords } => {
if let Some(s) = view.nodes.get_mut(&e.key) {
s.semantic = coords.clone();
}
}
WalPayload::Epoch { .. } => {}
}
}
}
fn decode_range(coords: &[u8], dim_range: &Range<usize>) -> Vec<f64> {
dim_range
.clone()
.map(|dim| {
let start = dim * 16;
let end = start + 16;
if coords.len() >= end {
g_math::fixed_point::FixedPoint::from_raw(i128::from_le_bytes(
coords[start..end].try_into().unwrap(),
))
.to_f64()
} else {
0.0
}
})
.collect()
}