use anyhow::{Context, Result};
use exocortex_kernel::{Memory, Relationship};
use exocortex_storage::Storage;
use futures::StreamExt;
use std::path::Path;
pub const FORMAT: &str = "exocortex-corpus";
pub const VERSION: u32 = 1;
#[derive(serde::Serialize)]
pub struct LineageRow {
pub id: String,
pub memory_type: String,
pub provenance: String,
pub source: String,
pub producer_kind: Option<String>,
pub external_key: Option<String>,
pub entities: Vec<String>,
pub lsn: u64,
pub rights: String,
}
#[derive(serde::Serialize)]
pub struct CorpusManifest {
pub format: String,
pub version: u32,
pub compatibility_fingerprint: String,
pub as_of: Option<String>,
pub memories: usize,
pub edges: usize,
pub computed_only_kinds: Vec<String>,
pub egress: String,
}
fn memory_believed_at(memory: &Memory, as_of: chrono::DateTime<chrono::Utc>) -> bool {
memory.recorded_at <= as_of
&& memory.valid_from <= as_of
&& memory.valid_until.is_none_or(|until| until > as_of)
}
fn edge_believed_at(edge: &Relationship, as_of: chrono::DateTime<chrono::Utc>) -> bool {
edge.recorded_at <= as_of
&& edge.valid_from <= as_of
&& edge.valid_until.is_none_or(|until| until > as_of)
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(out, "{b:02x}");
}
out
}
pub async fn export_corpus<S: Storage>(
storage: &S,
ontology: &exocortex_kernel::Ontology,
as_of: Option<chrono::DateTime<chrono::Utc>>,
dir: &Path,
) -> Result<CorpusManifest> {
let cut = as_of.unwrap_or_else(chrono::Utc::now);
std::fs::create_dir_all(dir)
.with_context(|| format!("create corpus directory {}", dir.display()))?;
let mut memories = Vec::new();
let mut stream = storage.stream_all_memories().await;
while let Some(row) = stream.next().await {
let memory = row.context("stream memory")?;
if memory_believed_at(&memory, cut) {
memories.push(memory);
}
}
let included: std::collections::HashSet<_> = memories.iter().map(|m| m.id).collect();
let mut edges = Vec::new();
let mut stream = storage.stream_all_relationships().await;
while let Some(row) = stream.next().await {
let edge = row.context("stream relationship")?;
if edge_believed_at(&edge, cut)
&& included.contains(&edge.from)
&& included.contains(&edge.to)
{
edges.push(edge);
}
}
let mut computed_only_kinds = std::collections::BTreeSet::new();
for edge in &edges {
if let Some(kind) = ontology.kinds_by_id.get(&edge.kind) {
if kind.computed_only {
computed_only_kinds.insert(kind.display_name.to_string());
}
}
}
let mut memories_out = String::new();
let mut lineage_out = String::new();
let mut lineage_rows = Vec::with_capacity(memories.len());
for memory in &memories {
memories_out.push_str(&serde_json::to_string(memory).context("serialize memory row")?);
memories_out.push('\n');
let (provenance, source, producer_kind, external_key) = match &memory.provenance {
exocortex_kernel::Provenance::Asserted {
author,
producer_kind,
} => (
"asserted".to_string(),
author.to_string(),
producer_kind.map(|kind| format!("{kind:?}")),
None,
),
exocortex_kernel::Provenance::Extracted { .. } => {
("extracted".to_string(), String::new(), None, None)
}
exocortex_kernel::Provenance::Derived { rule_id, .. } => {
("derived".to_string(), rule_id.to_string(), None, None)
}
exocortex_kernel::Provenance::Computed { .. } => {
("computed".to_string(), String::new(), None, None)
}
exocortex_kernel::Provenance::Proposed { .. } => {
("proposed".to_string(), String::new(), None, None)
}
exocortex_kernel::Provenance::ExternalSnapshot(snapshot) => {
let key = if snapshot.external_key.table_uuid.is_empty() {
None
} else {
Some(format!(
"{}:{}",
snapshot.external_key.table_uuid,
String::from_utf8_lossy(&snapshot.external_key.logical_pk)
))
};
(
"external-snapshot".to_string(),
snapshot.source_uri.to_string(),
Some(snapshot.producer_id.to_string()),
key,
)
}
};
let row = LineageRow {
id: hex(&memory.id.0),
memory_type: ontology
.memory_type_names
.get(memory.memory_type as usize)
.map(|name| name.to_string())
.unwrap_or_default(),
provenance,
source,
producer_kind,
external_key,
entities: memory
.context
.entities
.iter()
.map(|entity| hex(&entity.0))
.collect(),
lsn: memory.lsn.value,
rights: match &memory.rights {
Some(rights) if rights.egress_permitted() => "licensed".into(),
Some(_) => "partial".into(),
None => "none".into(),
},
};
lineage_out.push_str(&serde_json::to_string(&row).context("serialize lineage row")?);
lineage_out.push('\n');
lineage_rows.push(row);
}
let mut edges_out = String::new();
for edge in &edges {
edges_out.push_str(&serde_json::to_string(edge).context("serialize edge row")?);
edges_out.push('\n');
}
let licensed = memories
.iter()
.filter(|memory| {
memory
.rights
.as_ref()
.is_some_and(exocortex_kernel::memory::Rights::egress_permitted)
})
.count();
let partial = memories
.iter()
.filter(|memory| {
memory
.rights
.as_ref()
.is_some_and(|rights| !rights.egress_permitted())
})
.count();
let egress = if memories.is_empty() {
"empty corpus: no rows, no egress claim".to_string()
} else if licensed == memories.len() {
format!("permitted: all {licensed} exported rows claim licence + consent basis (D24)")
} else {
format!(
"NOT permitted: {licensed}/{} rows claim licence + consent; {partial} carry incomplete rights; {} claim none — a corpus leaves the org only when every row is covered (D24, fail closed)",
memories.len(),
memories.len() - licensed - partial
)
};
let manifest = CorpusManifest {
format: FORMAT.into(),
version: VERSION,
compatibility_fingerprint: hex(&ontology.fingerprint.0),
as_of: as_of.map(|t| t.to_rfc3339()),
memories: memories.len(),
edges: edges.len(),
computed_only_kinds: computed_only_kinds.into_iter().collect(),
egress,
};
let noun = "corpus export";
exocortex_storage::bounded_io::atomic_write_private(
&dir.join("memories.jsonl"),
memories_out.as_bytes(),
noun,
)
.with_context(|| format!("write {}", dir.join("memories.jsonl").display()))?;
exocortex_storage::bounded_io::atomic_write_private(
&dir.join("edges.jsonl"),
edges_out.as_bytes(),
noun,
)
.with_context(|| format!("write {}", dir.join("edges.jsonl").display()))?;
exocortex_storage::bounded_io::atomic_write_private(
&dir.join("lineage.jsonl"),
lineage_out.as_bytes(),
noun,
)
.with_context(|| format!("write {}", dir.join("lineage.jsonl").display()))?;
exocortex_storage::bounded_io::atomic_write_private(
&dir.join("manifest.json"),
serde_json::to_vec_pretty(&manifest)
.context("serialize manifest")?
.as_slice(),
noun,
)
.with_context(|| format!("write {}", dir.join("manifest.json").display()))?;
Ok(manifest)
}