use std::io::{BufRead, BufReader, Read, Write};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use ant_types::{Belief, Evidence, Observation, SchemaType, Vertex};
pub const FORMAT_VERSION: &str = "0.3";
pub const EXTENSION: &str = "ant";
pub const SUPPORTED_FORMAT_VERSION: &str = FORMAT_VERSION;
pub const FORMAT_MAJOR: u32 = 0;
pub const FORMAT_MINOR: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct FormatVersion {
pub major: u32,
pub minor: u32,
}
impl FormatVersion {
pub const CURRENT: FormatVersion = FormatVersion {
major: FORMAT_MAJOR,
minor: FORMAT_MINOR,
};
pub fn parse(s: &str) -> Option<Self> {
let mut it = s.trim().splitn(2, '.');
let major = it.next()?.parse().ok()?;
let minor = match it.next() {
None => 0,
Some(m) => m.parse().ok()?,
};
Some(FormatVersion { major, minor })
}
pub fn readable_by_current(&self) -> bool {
self.major == FORMAT_MAJOR
}
}
impl std::fmt::Display for FormatVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}", self.major, self.minor)
}
}
#[derive(Debug, thiserror::Error)]
pub enum AntError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json on line {line}: {err}")]
Json {
line: u64,
err: String,
},
#[error("not an .ant stream: {0}")]
NotAnt(String),
#[error("{0}")]
Version(String),
#[error("integrity: {0}")]
Integrity(String),
}
pub use ant_types::Edge;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VectorRecord {
pub record_type: String,
pub record_id: String,
pub label: String,
pub field: String,
pub vector: Vec<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_preview: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
pub format: String,
pub version: String,
pub tenant_id: u64,
pub project_id: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub producer: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Counts {
pub schema_types: u64,
pub vertices: u64,
pub edges: u64,
pub observations: u64,
pub evidence: u64,
pub beliefs: u64,
pub vectors: u64,
#[serde(default)]
pub vertex_tombstones: u64,
#[serde(default)]
pub edge_tombstones: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tombstone {
pub id: String,
pub deleted_at: chrono::DateTime<chrono::Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<ant_types::AuthorStamp>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AntRecord {
Manifest(Manifest),
SchemaType {
data: SchemaType,
},
Vertex {
data: Vertex,
},
Edge {
data: Edge,
},
Observation {
data: Observation,
},
Evidence {
data: Evidence,
},
Belief {
data: Belief,
},
Vector {
data: VectorRecord,
},
VertexTombstone {
data: Tombstone,
},
EdgeTombstone {
data: Tombstone,
},
Trailer {
counts: Counts,
sha256: String,
},
}
pub struct AntWriter<W: Write> {
enc: zstd::stream::write::Encoder<'static, W>,
hasher: Sha256,
counts: Counts,
finished: bool,
}
impl<W: Write> AntWriter<W> {
pub fn new(out: W, manifest: Manifest, level: i32) -> Result<Self, AntError> {
let enc = zstd::stream::write::Encoder::new(out, level)?;
let mut w = Self {
enc,
hasher: Sha256::new(),
counts: Counts::default(),
finished: false,
};
w.write_record(&AntRecord::Manifest(manifest))?;
Ok(w)
}
fn write_record(&mut self, rec: &AntRecord) -> Result<(), AntError> {
let mut line = serde_json::to_string(rec).map_err(|e| AntError::Json {
line: 0,
err: e.to_string(),
})?;
line.push('\n');
self.hasher.update(line.as_bytes());
self.enc.write_all(line.as_bytes())?;
Ok(())
}
pub fn write(&mut self, rec: AntRecord) -> Result<(), AntError> {
match &rec {
AntRecord::Manifest(_) => {
return Err(AntError::NotAnt("manifest may only appear first".into()))
}
AntRecord::Trailer { .. } => {
return Err(AntError::NotAnt("trailer is written by finish()".into()))
}
AntRecord::SchemaType { .. } => self.counts.schema_types += 1,
AntRecord::Vertex { .. } => self.counts.vertices += 1,
AntRecord::Edge { .. } => self.counts.edges += 1,
AntRecord::Observation { .. } => self.counts.observations += 1,
AntRecord::Evidence { .. } => self.counts.evidence += 1,
AntRecord::Belief { .. } => self.counts.beliefs += 1,
AntRecord::Vector { .. } => self.counts.vectors += 1,
AntRecord::VertexTombstone { .. } => self.counts.vertex_tombstones += 1,
AntRecord::EdgeTombstone { .. } => self.counts.edge_tombstones += 1,
}
self.write_record(&rec)
}
pub fn counts(&self) -> &Counts {
&self.counts
}
pub fn finish(mut self) -> Result<W, AntError> {
let digest = format!("{:x}", self.hasher.clone().finalize());
let trailer = AntRecord::Trailer {
counts: self.counts.clone(),
sha256: digest,
};
let mut line = serde_json::to_string(&trailer).map_err(|e| AntError::Json {
line: 0,
err: e.to_string(),
})?;
line.push('\n');
self.enc.write_all(line.as_bytes())?;
self.finished = true;
Ok(self.enc.finish()?)
}
}
pub struct AntReader<R: Read> {
lines: std::io::Lines<BufReader<zstd::stream::read::Decoder<'static, BufReader<R>>>>,
pub manifest: Manifest,
hasher: Sha256,
counts: Counts,
line_no: u64,
pub verified: bool,
pub version: FormatVersion,
pub minor_ahead: bool,
}
impl<R: Read> AntReader<R> {
pub fn new(input: R) -> Result<Self, AntError> {
let dec = zstd::stream::read::Decoder::new(input)
.map_err(|e| AntError::NotAnt(format!("zstd: {e}")))?;
let mut lines = BufReader::new(dec).lines();
let first = lines
.next()
.ok_or_else(|| AntError::NotAnt("empty stream".into()))??;
let rec: AntRecord = serde_json::from_str(&first).map_err(|e| AntError::Json {
line: 1,
err: e.to_string(),
})?;
let AntRecord::Manifest(manifest) = rec else {
return Err(AntError::NotAnt("first record is not a manifest".into()));
};
if manifest.format != "antares" {
return Err(AntError::NotAnt(format!("format `{}`", manifest.format)));
}
let file_version = FormatVersion::parse(&manifest.version).ok_or_else(|| {
AntError::Version(format!(
"manifest version `{}` is not MAJOR.MINOR; this reader implements {}",
manifest.version,
FormatVersion::CURRENT
))
})?;
if !file_version.readable_by_current() {
return Err(AntError::Version(format!(
"file is format v{file_version}, this reader implements v{}. \
Major versions are not compatible: a major bump means field \
meanings or the container framing changed, so reading it here \
would silently misinterpret records. Upgrade the reader to a \
v{}.x build, or re-export the file at v{}.",
FormatVersion::CURRENT,
file_version.major,
FORMAT_MAJOR,
)));
}
let minor_ahead = file_version.minor > FORMAT_MINOR;
let mut hasher = Sha256::new();
hasher.update(first.as_bytes());
hasher.update(b"\n");
Ok(Self {
lines,
manifest,
hasher,
counts: Counts::default(),
line_no: 1,
verified: false,
version: file_version,
minor_ahead,
})
}
pub fn next_record(&mut self) -> Result<Option<AntRecord>, AntError> {
loop {
let Some(line) = self.lines.next() else {
return Err(AntError::Integrity(
"stream ended without a trailer (truncated?)".into(),
));
};
let line = line?;
self.line_no += 1;
let pre_trailer_digest = format!("{:x}", self.hasher.clone().finalize());
self.hasher.update(line.as_bytes());
self.hasher.update(b"\n");
match serde_json::from_str::<AntRecord>(&line) {
Ok(AntRecord::Manifest(_)) => {
return Err(AntError::NotAnt("duplicate manifest".into()))
}
Ok(AntRecord::Trailer { counts, sha256 }) => {
if sha256 != pre_trailer_digest {
return Err(AntError::Integrity(format!(
"sha256 mismatch: trailer {sha256}, computed {pre_trailer_digest}"
)));
}
if counts != self.counts {
return Err(AntError::Integrity(format!(
"counts mismatch: trailer {counts:?}, read {:?}",
self.counts
)));
}
self.verified = true;
return Ok(None);
}
Ok(rec) => {
match &rec {
AntRecord::SchemaType { .. } => self.counts.schema_types += 1,
AntRecord::Vertex { .. } => self.counts.vertices += 1,
AntRecord::Edge { .. } => self.counts.edges += 1,
AntRecord::Observation { .. } => self.counts.observations += 1,
AntRecord::Evidence { .. } => self.counts.evidence += 1,
AntRecord::Belief { .. } => self.counts.beliefs += 1,
AntRecord::Vector { .. } => self.counts.vectors += 1,
AntRecord::VertexTombstone { .. } => self.counts.vertex_tombstones += 1,
AntRecord::EdgeTombstone { .. } => self.counts.edge_tombstones += 1,
AntRecord::Manifest(_) | AntRecord::Trailer { .. } => unreachable!(),
}
return Ok(Some(rec));
}
Err(e) => {
let probe: Result<serde_json::Value, _> = serde_json::from_str(&line);
match probe {
Ok(v) if v.get("kind").and_then(|k| k.as_str()).is_some() => continue,
_ => {
return Err(AntError::Json {
line: self.line_no,
err: e.to_string(),
})
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ant_types::{ObservationId, ProjectId, TenantId, TypeName, VertexId};
use std::collections::BTreeMap;
fn manifest() -> Manifest {
Manifest {
format: "antares".into(),
version: FORMAT_VERSION.into(),
tenant_id: 1,
project_id: 1,
selection: Some(serde_json::json!({"kind": "whole_scope"})),
created_at: None,
producer: Some("antares-format tests".into()),
}
}
fn sample_vertex() -> Vertex {
let mut props = BTreeMap::new();
props.insert("amount".into(), ant_types::PropertyValue::Long(42));
props.insert(
"doc".into(),
ant_types::PropertyValue::Json(serde_json::json!({"nested": [1, 2]})),
);
Vertex {
id: VertexId("d1".into()),
name: "Deal".into(),
label: TypeName("Antares.Deal".into()),
properties: props,
}
}
fn sample_obs() -> Observation {
Observation {
id: ObservationId("o1".into()),
tenant_id: TenantId(1),
project_id: ProjectId(1),
source_event_id: None,
source_uri: None,
subject_id: Some(VertexId("d1".into())),
predicate: "stage_change".into(),
object_id: None,
object_value: Some(serde_json::json!("proposal")),
observed_at: "2026-08-09T00:00:00Z".parse().unwrap(),
extracted_at: "2026-08-09T00:00:01Z".parse().unwrap(),
confidence: Some(0.9),
evidence_ids: vec![],
extractor_version: Some("test/1".into()),
metadata: serde_json::Value::Null,
author: None,
}
}
fn write_sample() -> Vec<u8> {
let mut w = AntWriter::new(Vec::new(), manifest(), 0).unwrap();
w.write(AntRecord::Vertex {
data: sample_vertex(),
})
.unwrap();
w.write(AntRecord::Observation { data: sample_obs() })
.unwrap();
w.write(AntRecord::Vector {
data: VectorRecord {
record_type: "evidence".into(),
record_id: "e1".into(),
label: "Antares.Chunk".into(),
field: "content".into(),
vector: vec![0.1, 0.2, 0.3],
text_preview: None,
evidence_ids: vec![],
},
})
.unwrap();
w.finish().unwrap()
}
#[test]
fn round_trip_verifies_and_preserves_records() {
let bytes = write_sample();
let mut r = AntReader::new(&bytes[..]).unwrap();
assert_eq!(r.manifest.project_id, 1);
let mut got = Vec::new();
while let Some(rec) = r.next_record().unwrap() {
got.push(rec);
}
assert!(r.verified, "trailer hash + counts verified");
assert_eq!(got.len(), 3);
assert_eq!(
got[0],
AntRecord::Vertex {
data: sample_vertex()
},
"typed properties (incl. Json variant) survive the round trip"
);
assert_eq!(got[1], AntRecord::Observation { data: sample_obs() });
}
#[test]
fn tampering_and_truncation_are_detected() {
let bytes = write_sample();
let mut bad = bytes.clone();
let mid = bad.len() / 2;
bad[mid] ^= 0xff;
let corrupted = (|| -> Result<(), AntError> {
let mut r = AntReader::new(&bad[..])?;
while r.next_record()?.is_some() {}
Ok(())
})()
.is_err();
assert!(corrupted, "bit-flip must not verify");
let cut = &bytes[..bytes.len() - 8];
let truncated = (|| -> Result<(), AntError> {
let mut r = AntReader::new(cut)?;
while r.next_record()?.is_some() {}
Ok(())
})()
.is_err();
assert!(truncated, "truncation must surface");
}
#[test]
fn unknown_kinds_are_skipped_for_forward_compat() {
use sha2::{Digest, Sha256};
let m = serde_json::to_string(&AntRecord::Manifest(manifest())).unwrap();
let v = serde_json::to_string(&AntRecord::Vertex {
data: sample_vertex(),
})
.unwrap();
let unknown = r#"{"kind":"hologram","data":{"future":true}}"#;
let mut hasher = Sha256::new();
for line in [&m, &v, &unknown.to_string()] {
hasher.update(line.as_bytes());
hasher.update(b"\n");
}
let trailer = AntRecord::Trailer {
counts: Counts {
vertices: 1,
..Default::default()
},
sha256: format!("{:x}", hasher.finalize()),
};
let t = serde_json::to_string(&trailer).unwrap();
let raw = format!("{m}\n{v}\n{unknown}\n{t}\n");
let compressed = zstd::stream::encode_all(raw.as_bytes(), 0).unwrap();
let mut r = AntReader::new(&compressed[..]).unwrap();
let mut kinds = Vec::new();
while let Some(rec) = r.next_record().unwrap() {
kinds.push(matches!(rec, AntRecord::Vertex { .. }));
}
assert!(r.verified);
assert_eq!(kinds, vec![true], "unknown kind skipped, vertex kept");
}
#[test]
fn wrong_version_and_non_ant_input_rejected() {
let mut bad_manifest = manifest();
bad_manifest.version = "9.9".into();
let w = AntWriter::new(Vec::new(), bad_manifest, 0).unwrap();
let bytes = w.finish().unwrap();
assert!(matches!(
AntReader::new(&bytes[..]),
Err(AntError::Version(_))
));
assert!(AntReader::new(&b"not zstd at all"[..]).is_err());
}
}