1use std::io::{BufRead, BufReader, Read, Write};
82
83use serde::{Deserialize, Serialize};
84use sha2::{Digest, Sha256};
85
86use ant_types::{Belief, Evidence, Observation, SchemaType, Vertex};
87
88pub const FORMAT_VERSION: &str = "0.3";
89pub const EXTENSION: &str = "ant";
90
91pub const SUPPORTED_FORMAT_VERSION: &str = FORMAT_VERSION;
97
98pub const FORMAT_MAJOR: u32 = 0;
100pub const FORMAT_MINOR: u32 = 3;
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
132pub struct FormatVersion {
133 pub major: u32,
134 pub minor: u32,
135}
136
137impl FormatVersion {
138 pub const CURRENT: FormatVersion = FormatVersion {
140 major: FORMAT_MAJOR,
141 minor: FORMAT_MINOR,
142 };
143
144 pub fn parse(s: &str) -> Option<Self> {
146 let mut it = s.trim().splitn(2, '.');
147 let major = it.next()?.parse().ok()?;
148 let minor = match it.next() {
149 None => 0,
150 Some(m) => m.parse().ok()?,
151 };
152 Some(FormatVersion { major, minor })
153 }
154
155 pub fn readable_by_current(&self) -> bool {
157 self.major == FORMAT_MAJOR
158 }
159}
160
161impl std::fmt::Display for FormatVersion {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 write!(f, "{}.{}", self.major, self.minor)
164 }
165}
166
167#[derive(Debug, thiserror::Error)]
168pub enum AntError {
169 #[error("io: {0}")]
170 Io(#[from] std::io::Error),
171 #[error("json on line {line}: {err}")]
172 Json { line: u64, err: String },
173 #[error("not an .ant stream: {0}")]
174 NotAnt(String),
175 #[error("{0}")]
179 Version(String),
180 #[error("integrity: {0}")]
181 Integrity(String),
182}
183
184pub use ant_types::Edge;
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct VectorRecord {
191 pub record_type: String,
192 pub record_id: String,
193 pub label: String,
194 pub field: String,
195 pub vector: Vec<f32>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub text_preview: Option<String>,
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub evidence_ids: Vec<String>,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct Manifest {
205 pub format: String,
207 pub version: String,
209 pub tenant_id: u64,
210 pub project_id: u64,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub selection: Option<serde_json::Value>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub producer: Option<String>,
220}
221
222#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct Counts {
225 pub schema_types: u64,
226 pub vertices: u64,
227 pub edges: u64,
228 pub observations: u64,
229 pub evidence: u64,
230 pub beliefs: u64,
231 pub vectors: u64,
232 #[serde(default)]
235 pub vertex_tombstones: u64,
236 #[serde(default)]
237 pub edge_tombstones: u64,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct Tombstone {
278 pub id: String,
280 pub deleted_at: chrono::DateTime<chrono::Utc>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub author: Option<ant_types::AuthorStamp>,
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291#[serde(tag = "kind", rename_all = "snake_case")]
292pub enum AntRecord {
293 Manifest(Manifest),
294 SchemaType {
295 data: SchemaType,
296 },
297 Vertex {
298 data: Vertex,
299 },
300 Edge {
301 data: Edge,
302 },
303 Observation {
304 data: Observation,
305 },
306 Evidence {
307 data: Evidence,
308 },
309 Belief {
310 data: Belief,
311 },
312 Vector {
313 data: VectorRecord,
314 },
315 VertexTombstone {
318 data: Tombstone,
319 },
320 EdgeTombstone {
322 data: Tombstone,
323 },
324 Trailer {
325 counts: Counts,
326 sha256: String,
327 },
328}
329
330pub struct AntWriter<W: Write> {
333 enc: zstd::stream::write::Encoder<'static, W>,
334 hasher: Sha256,
335 counts: Counts,
336 finished: bool,
337}
338
339impl<W: Write> AntWriter<W> {
340 pub fn new(out: W, manifest: Manifest, level: i32) -> Result<Self, AntError> {
344 let enc = zstd::stream::write::Encoder::new(out, level)?;
345 let mut w = Self {
346 enc,
347 hasher: Sha256::new(),
348 counts: Counts::default(),
349 finished: false,
350 };
351 w.write_record(&AntRecord::Manifest(manifest))?;
352 Ok(w)
353 }
354
355 fn write_record(&mut self, rec: &AntRecord) -> Result<(), AntError> {
356 let mut line = serde_json::to_string(rec).map_err(|e| AntError::Json {
357 line: 0,
358 err: e.to_string(),
359 })?;
360 line.push('\n');
361 self.hasher.update(line.as_bytes());
362 self.enc.write_all(line.as_bytes())?;
363 Ok(())
364 }
365
366 pub fn write(&mut self, rec: AntRecord) -> Result<(), AntError> {
367 match &rec {
368 AntRecord::Manifest(_) => {
369 return Err(AntError::NotAnt("manifest may only appear first".into()))
370 }
371 AntRecord::Trailer { .. } => {
372 return Err(AntError::NotAnt("trailer is written by finish()".into()))
373 }
374 AntRecord::SchemaType { .. } => self.counts.schema_types += 1,
375 AntRecord::Vertex { .. } => self.counts.vertices += 1,
376 AntRecord::Edge { .. } => self.counts.edges += 1,
377 AntRecord::Observation { .. } => self.counts.observations += 1,
378 AntRecord::Evidence { .. } => self.counts.evidence += 1,
379 AntRecord::Belief { .. } => self.counts.beliefs += 1,
380 AntRecord::Vector { .. } => self.counts.vectors += 1,
381 AntRecord::VertexTombstone { .. } => self.counts.vertex_tombstones += 1,
382 AntRecord::EdgeTombstone { .. } => self.counts.edge_tombstones += 1,
383 }
384 self.write_record(&rec)
385 }
386
387 pub fn counts(&self) -> &Counts {
389 &self.counts
390 }
391
392 pub fn finish(mut self) -> Result<W, AntError> {
395 let digest = format!("{:x}", self.hasher.clone().finalize());
396 let trailer = AntRecord::Trailer {
397 counts: self.counts.clone(),
398 sha256: digest,
399 };
400 let mut line = serde_json::to_string(&trailer).map_err(|e| AntError::Json {
401 line: 0,
402 err: e.to_string(),
403 })?;
404 line.push('\n');
405 self.enc.write_all(line.as_bytes())?;
406 self.finished = true;
407 Ok(self.enc.finish()?)
408 }
409}
410
411pub struct AntReader<R: Read> {
415 lines: std::io::Lines<BufReader<zstd::stream::read::Decoder<'static, BufReader<R>>>>,
416 pub manifest: Manifest,
417 hasher: Sha256,
418 counts: Counts,
419 line_no: u64,
420 pub verified: bool,
422 pub version: FormatVersion,
425 pub minor_ahead: bool,
430}
431
432impl<R: Read> AntReader<R> {
433 pub fn new(input: R) -> Result<Self, AntError> {
434 let dec = zstd::stream::read::Decoder::new(input)
435 .map_err(|e| AntError::NotAnt(format!("zstd: {e}")))?;
436 let mut lines = BufReader::new(dec).lines();
437 let first = lines
438 .next()
439 .ok_or_else(|| AntError::NotAnt("empty stream".into()))??;
440 let rec: AntRecord = serde_json::from_str(&first).map_err(|e| AntError::Json {
441 line: 1,
442 err: e.to_string(),
443 })?;
444 let AntRecord::Manifest(manifest) = rec else {
445 return Err(AntError::NotAnt("first record is not a manifest".into()));
446 };
447 if manifest.format != "antares" {
448 return Err(AntError::NotAnt(format!("format `{}`", manifest.format)));
449 }
450 let file_version = FormatVersion::parse(&manifest.version).ok_or_else(|| {
453 AntError::Version(format!(
454 "manifest version `{}` is not MAJOR.MINOR; this reader implements {}",
455 manifest.version,
456 FormatVersion::CURRENT
457 ))
458 })?;
459 if !file_version.readable_by_current() {
460 return Err(AntError::Version(format!(
461 "file is format v{file_version}, this reader implements v{}. \
462 Major versions are not compatible: a major bump means field \
463 meanings or the container framing changed, so reading it here \
464 would silently misinterpret records. Upgrade the reader to a \
465 v{}.x build, or re-export the file at v{}.",
466 FormatVersion::CURRENT,
467 file_version.major,
468 FORMAT_MAJOR,
469 )));
470 }
471 let minor_ahead = file_version.minor > FORMAT_MINOR;
472 let mut hasher = Sha256::new();
473 hasher.update(first.as_bytes());
474 hasher.update(b"\n");
475 Ok(Self {
476 lines,
477 manifest,
478 hasher,
479 counts: Counts::default(),
480 line_no: 1,
481 verified: false,
482 version: file_version,
483 minor_ahead,
484 })
485 }
486
487 pub fn next_record(&mut self) -> Result<Option<AntRecord>, AntError> {
490 loop {
491 let Some(line) = self.lines.next() else {
492 return Err(AntError::Integrity(
493 "stream ended without a trailer (truncated?)".into(),
494 ));
495 };
496 let line = line?;
497 self.line_no += 1;
498 let pre_trailer_digest = format!("{:x}", self.hasher.clone().finalize());
500 self.hasher.update(line.as_bytes());
501 self.hasher.update(b"\n");
502 match serde_json::from_str::<AntRecord>(&line) {
503 Ok(AntRecord::Manifest(_)) => {
504 return Err(AntError::NotAnt("duplicate manifest".into()))
505 }
506 Ok(AntRecord::Trailer { counts, sha256 }) => {
507 if sha256 != pre_trailer_digest {
508 return Err(AntError::Integrity(format!(
509 "sha256 mismatch: trailer {sha256}, computed {pre_trailer_digest}"
510 )));
511 }
512 if counts != self.counts {
513 return Err(AntError::Integrity(format!(
514 "counts mismatch: trailer {counts:?}, read {:?}",
515 self.counts
516 )));
517 }
518 self.verified = true;
519 return Ok(None);
520 }
521 Ok(rec) => {
522 match &rec {
523 AntRecord::SchemaType { .. } => self.counts.schema_types += 1,
524 AntRecord::Vertex { .. } => self.counts.vertices += 1,
525 AntRecord::Edge { .. } => self.counts.edges += 1,
526 AntRecord::Observation { .. } => self.counts.observations += 1,
527 AntRecord::Evidence { .. } => self.counts.evidence += 1,
528 AntRecord::Belief { .. } => self.counts.beliefs += 1,
529 AntRecord::Vector { .. } => self.counts.vectors += 1,
530 AntRecord::VertexTombstone { .. } => self.counts.vertex_tombstones += 1,
531 AntRecord::EdgeTombstone { .. } => self.counts.edge_tombstones += 1,
532 AntRecord::Manifest(_) | AntRecord::Trailer { .. } => unreachable!(),
533 }
534 return Ok(Some(rec));
535 }
536 Err(e) => {
537 let probe: Result<serde_json::Value, _> = serde_json::from_str(&line);
540 match probe {
541 Ok(v) if v.get("kind").and_then(|k| k.as_str()).is_some() => continue,
542 _ => {
543 return Err(AntError::Json {
544 line: self.line_no,
545 err: e.to_string(),
546 })
547 }
548 }
549 }
550 }
551 }
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use ant_types::{ObservationId, ProjectId, TenantId, TypeName, VertexId};
559 use std::collections::BTreeMap;
560
561 fn manifest() -> Manifest {
562 Manifest {
563 format: "antares".into(),
564 version: FORMAT_VERSION.into(),
565 tenant_id: 1,
566 project_id: 1,
567 selection: Some(serde_json::json!({"kind": "whole_scope"})),
568 created_at: None,
569 producer: Some("antares-format tests".into()),
570 }
571 }
572
573 fn sample_vertex() -> Vertex {
574 let mut props = BTreeMap::new();
575 props.insert("amount".into(), ant_types::PropertyValue::Long(42));
576 props.insert(
577 "doc".into(),
578 ant_types::PropertyValue::Json(serde_json::json!({"nested": [1, 2]})),
579 );
580 Vertex {
581 id: VertexId("d1".into()),
582 name: "Deal".into(),
583 label: TypeName("Antares.Deal".into()),
584 properties: props,
585 }
586 }
587
588 fn sample_obs() -> Observation {
589 Observation {
590 id: ObservationId("o1".into()),
591 tenant_id: TenantId(1),
592 project_id: ProjectId(1),
593 source_event_id: None,
594 source_uri: None,
595 subject_id: Some(VertexId("d1".into())),
596 predicate: "stage_change".into(),
597 object_id: None,
598 object_value: Some(serde_json::json!("proposal")),
599 observed_at: "2026-08-09T00:00:00Z".parse().unwrap(),
600 extracted_at: "2026-08-09T00:00:01Z".parse().unwrap(),
601 confidence: Some(0.9),
602 evidence_ids: vec![],
603 extractor_version: Some("test/1".into()),
604 metadata: serde_json::Value::Null,
605 author: None,
606 }
607 }
608
609 fn write_sample() -> Vec<u8> {
610 let mut w = AntWriter::new(Vec::new(), manifest(), 0).unwrap();
611 w.write(AntRecord::Vertex {
612 data: sample_vertex(),
613 })
614 .unwrap();
615 w.write(AntRecord::Observation { data: sample_obs() })
616 .unwrap();
617 w.write(AntRecord::Vector {
618 data: VectorRecord {
619 record_type: "evidence".into(),
620 record_id: "e1".into(),
621 label: "Antares.Chunk".into(),
622 field: "content".into(),
623 vector: vec![0.1, 0.2, 0.3],
624 text_preview: None,
625 evidence_ids: vec![],
626 },
627 })
628 .unwrap();
629 w.finish().unwrap()
630 }
631
632 #[test]
633 fn round_trip_verifies_and_preserves_records() {
634 let bytes = write_sample();
635 let mut r = AntReader::new(&bytes[..]).unwrap();
636 assert_eq!(r.manifest.project_id, 1);
637 let mut got = Vec::new();
638 while let Some(rec) = r.next_record().unwrap() {
639 got.push(rec);
640 }
641 assert!(r.verified, "trailer hash + counts verified");
642 assert_eq!(got.len(), 3);
643 assert_eq!(
644 got[0],
645 AntRecord::Vertex {
646 data: sample_vertex()
647 },
648 "typed properties (incl. Json variant) survive the round trip"
649 );
650 assert_eq!(got[1], AntRecord::Observation { data: sample_obs() });
651 }
652
653 #[test]
654 fn tampering_and_truncation_are_detected() {
655 let bytes = write_sample();
656 let mut bad = bytes.clone();
659 let mid = bad.len() / 2;
660 bad[mid] ^= 0xff;
661 let corrupted = (|| -> Result<(), AntError> {
662 let mut r = AntReader::new(&bad[..])?;
663 while r.next_record()?.is_some() {}
664 Ok(())
665 })()
666 .is_err();
667 assert!(corrupted, "bit-flip must not verify");
668
669 let cut = &bytes[..bytes.len() - 8];
671 let truncated = (|| -> Result<(), AntError> {
672 let mut r = AntReader::new(cut)?;
673 while r.next_record()?.is_some() {}
674 Ok(())
675 })()
676 .is_err();
677 assert!(truncated, "truncation must surface");
678 }
679
680 #[test]
681 fn unknown_kinds_are_skipped_for_forward_compat() {
682 use sha2::{Digest, Sha256};
685 let m = serde_json::to_string(&AntRecord::Manifest(manifest())).unwrap();
686 let v = serde_json::to_string(&AntRecord::Vertex {
687 data: sample_vertex(),
688 })
689 .unwrap();
690 let unknown = r#"{"kind":"hologram","data":{"future":true}}"#;
691 let mut hasher = Sha256::new();
692 for line in [&m, &v, &unknown.to_string()] {
693 hasher.update(line.as_bytes());
694 hasher.update(b"\n");
695 }
696 let trailer = AntRecord::Trailer {
697 counts: Counts {
698 vertices: 1,
699 ..Default::default()
700 },
701 sha256: format!("{:x}", hasher.finalize()),
702 };
703 let t = serde_json::to_string(&trailer).unwrap();
704 let raw = format!("{m}\n{v}\n{unknown}\n{t}\n");
705 let compressed = zstd::stream::encode_all(raw.as_bytes(), 0).unwrap();
706
707 let mut r = AntReader::new(&compressed[..]).unwrap();
708 let mut kinds = Vec::new();
709 while let Some(rec) = r.next_record().unwrap() {
710 kinds.push(matches!(rec, AntRecord::Vertex { .. }));
711 }
712 assert!(r.verified);
713 assert_eq!(kinds, vec![true], "unknown kind skipped, vertex kept");
714 }
715
716 #[test]
717 fn wrong_version_and_non_ant_input_rejected() {
718 let mut bad_manifest = manifest();
719 bad_manifest.version = "9.9".into();
720 let w = AntWriter::new(Vec::new(), bad_manifest, 0).unwrap();
721 let bytes = w.finish().unwrap();
722 assert!(matches!(
723 AntReader::new(&bytes[..]),
724 Err(AntError::Version(_))
725 ));
726 assert!(AntReader::new(&b"not zstd at all"[..]).is_err());
727 }
728}