use std::sync::Arc;
use lunaris_core::{
Chunk, Embedder, Episode, HlcClock, Lsn, LunarisError, StorageError, StoragePort, WriteOp,
keyspace::{fact_key as scoped_fact_key, fact_spo_key},
sanitize_graph_ident,
};
use lunaris_extract::{ChunkInput, NeedsReviewItem, ValidatedExtraction, validate};
use lunaris_ingest::{
BakoffConfig, ChunkDraft, HeadingRecord, TokenCounter, chunk_key, chunk_markdown_with_counter,
chunk_markdown_with_headings_with_counter, episode_key, ingest_episode_with_bakeoff,
run_bakeoff,
};
use serde_json::json;
use tracing::Instrument;
use ulid::Ulid;
use crate::graph_pipeline::GraphPipelineHandle;
use crate::handle::Lunaris;
const ENTITIES_INDEX: &str = "entities";
const FACTS_INDEX: &str = "facts";
const GRAPH_NAME: &str = "lunaris_graph";
const VERIFY_QUEUE_TOPIC: &str = "__lunaris_verify__";
const CONSOLIDATE_QUEUE_TOPIC: &str = "__lunaris_consolidate__";
const CHUNK_VECTOR_INDEX: &str = "chunks";
const DEFAULT_TARGET_TOKENS: usize = 500;
const DEFAULT_OVERLAP_TOKENS: usize = 100;
const EMBED_BATCH_SIZE: usize = 32;
impl Lunaris {
pub async fn ingest(&self, mut episode: Episode) -> Result<Lsn, LunarisError> {
episode.ground_valid_axis();
let episode_id = episode.id;
let episode_source = episode.source.clone();
let episode_scope = episode.scope.clone();
let span = tracing::info_span!(
"lunaris.ingest",
correlation_id = tracing::field::Empty,
episode_id = %episode_id,
graph_enabled = self.graph_pipeline.is_enabled(),
);
let token_counter = self.token_counter.clone();
async move {
let bakeoff_config = self.bakeoff_config.clone();
let lsn = if !self.graph_pipeline.is_enabled() {
let (target_tokens, overlap_tokens) = bakeoff_config
.as_deref()
.map(|c| (c.target_tokens, c.overlap_tokens))
.unwrap_or((DEFAULT_TARGET_TOKENS, DEFAULT_OVERLAP_TOKENS));
ingest_episode_with_bakeoff(
self.storage.as_ref(),
self.embedder.as_ref(),
&self.clock,
episode,
token_counter.clone(),
bakeoff_config,
target_tokens,
overlap_tokens,
)
.await?
} else {
ingest_episode_graph_on(
self.storage.as_ref(),
self.embedder.as_ref(),
&self.graph_pipeline,
&self.clock,
episode,
token_counter,
bakeoff_config,
graph_extract_per_session(),
)
.await?
};
publish_consolidate_event(
self.storage.as_ref(),
episode_id,
lsn,
&episode_source,
&episode_scope,
)
.await;
Ok(lsn)
}
.instrument(span)
.await
}
pub async fn ingest_structured(
&self,
payload: crate::structured_ingest::StructuredIngest,
scope: lunaris_core::Scope,
) -> Result<Lsn, LunarisError> {
crate::structured_ingest::ingest_structured_inner(
self.storage.as_ref(),
self.embedder.as_ref(),
&self.clock,
payload,
scope,
)
.await
}
}
async fn publish_consolidate_event(
storage: &dyn StoragePort,
episode_id: Ulid,
lsn: Lsn,
source: &str,
scope: &lunaris_core::Scope,
) {
if !storage.capabilities().queue_native {
tracing::debug!("consolidate queue unavailable; skipping consolidate-queue publish");
return;
}
let envelope = json!({
"kind": "ingest_committed",
"episode_id": episode_id.to_string(),
"lsn_wall_ms": lsn.wall_ms,
"lsn_counter": lsn.counter,
"source": source,
});
let payload = match serde_json::to_vec(&envelope) {
Ok(b) => b,
Err(e) => {
tracing::warn!(
err = %e,
"consolidate serialize failed; skipping consolidate-queue publish"
);
return;
}
};
if let Err(e) = storage.publish(scope, CONSOLIDATE_QUEUE_TOPIC, 0, payload.into()).await {
tracing::warn!(
err = %e,
"consolidate-queue publish failed; ingest still succeeded"
);
}
}
fn graph_extract_per_session() -> bool {
parse_graph_extract_granularity(
std::env::var("LUNARIS_GRAPH_EXTRACT_GRANULARITY").ok().as_deref(),
)
}
fn parse_graph_extract_granularity(raw: Option<&str>) -> bool {
match raw {
None => false,
Some(raw) => match raw.trim().to_ascii_lowercase().as_str() {
"session" | "episode" | "doc" => true,
"chunk" | "chunks" | "" => false,
other => {
tracing::warn!(
value = %other,
"ignoring invalid LUNARIS_GRAPH_EXTRACT_GRANULARITY (want session|chunk); \
defaulting to chunk (per-chunk extraction)"
);
false
}
},
}
}
fn build_extract_inputs(
episode_id: Ulid,
episode_content: &str,
chunks: &[Chunk],
per_session: bool,
reference_time_iso: Option<&str>,
) -> Vec<ChunkInput> {
let reference_time_iso = reference_time_iso.map(str::to_owned);
if per_session {
if episode_content.is_empty() {
return Vec::new();
}
vec![ChunkInput {
chunk_id: episode_id,
text: episode_content.to_string(),
heading_path: Vec::new(),
reference_time_iso,
}]
} else {
chunks
.iter()
.map(|c| ChunkInput {
chunk_id: c.id,
text: c.text.clone(),
heading_path: c.heading_path.clone(),
reference_time_iso: reference_time_iso.clone(),
})
.collect()
}
}
#[allow(clippy::too_many_arguments)]
async fn ingest_episode_graph_on(
storage: &dyn StoragePort,
embedder: &dyn Embedder,
graph_pipeline: &Arc<GraphPipelineHandle>,
clock: &HlcClock,
episode: Episode,
counter: std::sync::Arc<dyn TokenCounter + Send + Sync>,
bakeoff_config: Option<Arc<BakoffConfig>>,
per_session_extract: bool,
) -> Result<Lsn, LunarisError> {
let chunks: Vec<Chunk> = if let Some(ref cfg) = bakeoff_config {
let target_tokens = cfg.target_tokens;
let overlap_tokens = cfg.overlap_tokens;
let (_structural_drafts, heading_records): (Vec<ChunkDraft>, Vec<HeadingRecord>) =
chunk_markdown_with_headings_with_counter(
&episode.content,
target_tokens,
overlap_tokens,
counter.as_ref(),
);
let winner = run_bakeoff(
&episode.content,
heading_records,
cfg,
embedder,
counter.as_ref(),
target_tokens,
overlap_tokens,
)
.await?;
let mut out: Vec<Chunk> = Vec::with_capacity(winner.drafts.len());
for (draft, embedding) in winner.drafts.into_iter().zip(winner.embeddings.into_iter()) {
let mut c = draft.into_chunk_valid_from(
episode.scope.clone(),
episode.id,
clock,
episode.bt.valid.0,
);
c.embedding = Some(embedding);
out.push(c);
}
out
} else {
let drafts = chunk_markdown_with_counter(
&episode.content,
DEFAULT_TARGET_TOKENS,
DEFAULT_OVERLAP_TOKENS,
counter.as_ref(),
);
let embeddings = embed_with_fallback(embedder, &drafts).await?;
debug_assert_eq!(embeddings.len(), drafts.len());
let mut out: Vec<Chunk> = Vec::with_capacity(drafts.len());
for (draft, embedding) in drafts.into_iter().zip(embeddings.into_iter()) {
let mut c = draft.into_chunk_valid_from(
episode.scope.clone(),
episode.id,
clock,
episode.bt.valid.0,
);
c.embedding = Some(embedding);
out.push(c);
}
out
};
let extractor = graph_pipeline.snapshot_extractor().ok_or_else(|| {
LunarisError::Storage(StorageError::Backend(
"graph_pipeline enabled but no extractor installed".into(),
))
})?;
let mut validated: ValidatedExtraction = if extractor.applies() {
let reference_time_iso = episode.t_ref.map(|t| t.format("%Y-%m-%d").to_string());
let chunk_inputs: Vec<ChunkInput> = build_extract_inputs(
episode.id,
&episode.content,
&chunks,
per_session_extract,
reference_time_iso.as_deref(),
);
let extract_span = tracing::info_span!(
"lunaris.extract",
correlation_id = tracing::field::Empty,
episode_id = %episode.id,
chunk_count = chunk_inputs.len(),
);
let mut raw = extractor.extract(episode.id, &chunk_inputs).instrument(extract_span).await?;
if let Some(ref_date) = reference_time_iso.as_deref() {
lunaris_extract::cap_future_valid_from(&mut raw, ref_date);
}
validate(raw)
} else {
ValidatedExtraction::default()
};
let cap = 1
+ 2 * chunks.len()
+ 2 * validated.entities.len()
+ validated.relations.len()
+ 5 * validated.facts.len();
let mut ops: Vec<WriteOp> = Vec::with_capacity(cap);
let episode_value = serde_json::to_vec(&episode).map_err(|e| {
LunarisError::Storage(StorageError::Backend(format!("episode serialize: {e}")))
})?;
ops.push(WriteOp::KvPut { key: episode_key(&episode.scope, episode.id), value: episode_value });
for chunk in &chunks {
let chunk_value = serde_json::to_vec(chunk).map_err(|e| {
LunarisError::Storage(StorageError::Backend(format!("chunk serialize: {e}")))
})?;
ops.push(WriteOp::KvPut { key: chunk_key(&episode.scope, chunk.id), value: chunk_value });
let embedding = chunk.embedding.as_ref().expect("embedding assigned in step 2").clone();
ops.push(WriteOp::VectorUpsert {
index: CHUNK_VECTOR_INDEX.into(),
id: chunk.id.to_bytes().to_vec(),
embedding,
metadata: json!({
"episode_id": chunk.episode_id.to_string(),
"heading_path": chunk.heading_path,
"offset": chunk.offset,
"text": chunk.text,
"valid_time_ms": chunk.bt.valid.0.wall_ms,
"source": &episode.source,
}),
});
}
let graph_texts: Vec<&str> = validated
.entities
.iter()
.map(|e| e.name.as_str())
.chain(validated.facts.iter().map(|f| f.fact_text.as_str()))
.collect();
let graph_vecs: Vec<Vec<f32>> = if graph_texts.is_empty() {
Vec::new()
} else {
embed_texts_with_fallback(embedder, &graph_texts).await?
};
if graph_vecs.len() != graph_texts.len() {
return Err(LunarisError::Storage(StorageError::Backend(format!(
"graph embedding row mismatch: {} texts, {} vectors",
graph_texts.len(),
graph_vecs.len()
))));
}
let (entity_vecs, fact_vecs) = graph_vecs.split_at(validated.entities.len());
for (e, embedding) in validated.entities.iter().zip(entity_vecs) {
let id_bytes = e.id.0.to_vec();
ops.push(WriteOp::GraphNode {
graph: GRAPH_NAME.into(),
id: id_bytes.clone(),
label: sanitize_graph_ident(&e.entity_type, "Entity"),
props: json!({
"id_hex": format!("{}", e.id),
"name": e.name,
"type": e.entity_type,
"aliases": e.aliases,
"confidence": e.confidence,
"valid_from_iso": e.valid_from_iso,
"valid_to_iso": e.valid_to_iso,
}),
index_kind: "entities".into(),
});
ops.push(WriteOp::VectorUpsert {
index: ENTITIES_INDEX.into(),
id: id_bytes,
embedding: embedding.clone(),
metadata: json!({"entity_type": e.entity_type, "name": e.name}),
});
}
for r in &validated.relations {
ops.push(WriteOp::GraphEdge {
graph: GRAPH_NAME.into(),
src: r.subject_id.0.to_vec(),
dst: r.object_id.0.to_vec(),
rel: sanitize_graph_ident(&r.predicate, "RELATED_TO"),
props: json!({
"confidence": r.confidence,
"valid_from_iso": r.valid_from_iso,
"valid_to_iso": r.valid_to_iso,
}),
});
}
let spo_now = clock.tick();
let episode_ref_date = episode.t_ref;
let mut spo_index: std::collections::HashMap<Vec<u8>, Vec<crate::reconcile::SpoEntry>> =
std::collections::HashMap::new();
let mut spo_touched: Vec<Vec<u8>> = Vec::new();
for (f, embedding) in validated.facts.iter().zip(fact_vecs) {
let fact_id = Ulid::from_bytes(
lunaris_extract::types::FactId::from_triple(f.subject_id, &f.predicate, f.object_id).0,
);
let mut fact_row = f.clone();
fact_row.id = fact_id;
if let Some(valid_from) = resolve_fact_instant(&f.valid_from_iso, episode_ref_date) {
let valid_to = resolve_fact_instant(
f.valid_to_iso.as_deref().unwrap_or_default(),
None,
);
let spo_key = fact_spo_key(&episode.scope, &f.subject_id.0, &f.predicate);
if !spo_index.contains_key(&spo_key) {
let prior = crate::structured_ingest::read_spo_index(
storage,
&episode.scope,
&spo_key,
spo_now,
)
.await?;
spo_index.insert(spo_key.clone(), prior);
spo_touched.push(spo_key.clone());
}
let new_triple = crate::reconcile::FactTriple {
subject_id: f.subject_id,
predicate: f.predicate.clone(),
object_id: f.object_id,
valid_from,
valid_to,
};
let prior = &spo_index[&spo_key];
match crate::reconcile::classify_fact(&new_triple, prior) {
crate::reconcile::FactDecision::Noop => {
if let Some(entry) = spo_index
.get_mut(&spo_key)
.and_then(|v| v.iter_mut().find(|e| e.object_id == f.object_id))
{
entry.valid_from = valid_from;
entry.valid_to = valid_to;
}
}
crate::reconcile::FactDecision::Append => {
spo_index.get_mut(&spo_key).expect("seeded above").push(
crate::reconcile::SpoEntry {
object_id: f.object_id,
fact_id,
valid_from,
valid_to,
},
);
}
crate::reconcile::FactDecision::Supersede { loser_fact_id } => {
let existing_object = prior
.iter()
.find(|p| p.fact_id == loser_fact_id)
.map_or(f.object_id, |p| p.object_id);
validated.needs_review.push(NeedsReviewItem::Fact {
reason: lunaris_extract::NeedsReviewReason::CrossEpisodeContradiction {
subject: f.subject_id,
predicate: f.predicate.clone(),
existing_fact_id: loser_fact_id,
existing_object,
new_fact_id: fact_id,
new_object: f.object_id,
},
raw: fact_row.clone(),
});
spo_index.get_mut(&spo_key).expect("seeded above").push(
crate::reconcile::SpoEntry {
object_id: f.object_id,
fact_id,
valid_from,
valid_to,
},
);
}
}
}
let f = &fact_row;
let fact_value = serde_json::to_vec(f).map_err(|err| {
LunarisError::Storage(StorageError::Backend(format!("fact serialize: {err}")))
})?;
let fact_id_bytes = f.id.to_bytes().to_vec();
ops.push(WriteOp::KvPut { key: scoped_fact_key(&episode.scope, f.id), value: fact_value });
ops.push(WriteOp::VectorUpsert {
index: FACTS_INDEX.into(),
id: fact_id_bytes.clone(),
embedding: embedding.clone(),
metadata: json!({"predicate": f.predicate, "fact_text": f.fact_text}),
});
ops.push(WriteOp::GraphNode {
graph: GRAPH_NAME.into(),
id: fact_id_bytes.clone(),
label: "Fact".into(),
props: json!({
"id_hex": format!("{}", f.id),
"predicate": f.predicate,
"confidence": f.confidence,
"valid_from_iso": f.valid_from_iso,
"valid_to_iso": f.valid_to_iso,
}),
index_kind: "facts".into(),
});
ops.push(WriteOp::GraphEdge {
graph: GRAPH_NAME.into(),
src: f.subject_id.0.to_vec(),
dst: fact_id_bytes.clone(),
rel: "HAS_FACT".into(),
props: json!({}),
});
ops.push(WriteOp::GraphEdge {
graph: GRAPH_NAME.into(),
src: fact_id_bytes,
dst: f.object_id.0.to_vec(),
rel: "FACT_ABOUT".into(),
props: json!({}),
});
}
for key in spo_touched {
let entries = &spo_index[&key];
let value = serde_json::to_vec(&crate::structured_ingest::spo_entries_to_json(entries))
.map_err(|err| {
LunarisError::Storage(StorageError::Backend(format!("spo index serialize: {err}")))
})?;
ops.push(WriteOp::KvPut { key, value });
}
let lsn = storage.atomic_write(&episode.scope, &ops).await?;
publish_needs_review(storage, &episode.scope, &validated.needs_review).await;
Ok(lsn)
}
fn resolve_fact_instant(
iso: &str,
fallback: Option<chrono::DateTime<chrono::Utc>>,
) -> Option<chrono::DateTime<chrono::Utc>> {
let s = iso.trim();
if !s.is_empty() {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&chrono::Utc));
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Some(chrono::DateTime::from_naive_utc_and_offset(
d.and_time(chrono::NaiveTime::MIN),
chrono::Utc,
));
}
}
fallback
}
async fn embed_with_fallback(
embedder: &dyn Embedder,
drafts: &[ChunkDraft],
) -> Result<Vec<Vec<f32>>, LunarisError> {
let texts: Vec<&str> = drafts.iter().map(|d| d.text.as_str()).collect();
embed_texts_with_fallback(embedder, &texts).await
}
async fn embed_texts_with_fallback(
embedder: &dyn Embedder,
all_texts: &[&str],
) -> Result<Vec<Vec<f32>>, LunarisError> {
let mut out: Vec<Vec<f32>> = Vec::with_capacity(all_texts.len());
for batch in all_texts.chunks(EMBED_BATCH_SIZE) {
let texts: Vec<&str> = batch.to_vec();
match embedder.embed_batch(&texts).await {
Ok(rows) if rows.len() == texts.len() => out.extend(rows),
Ok(rows) => {
tracing::warn!(
expected = texts.len(),
got = rows.len(),
"embed_batch returned wrong row count; falling back to per-chunk"
);
for text in &texts {
let single = embedder.embed_batch(&[text]).await?;
out.push(single.into_iter().next().ok_or_else(|| {
LunarisError::Storage(StorageError::Backend(
"embed_batch returned 0 rows for single input".into(),
))
})?);
}
}
Err(batch_err) => {
tracing::warn!(
err = %batch_err,
batch_size = texts.len(),
"embed_batch failed; falling back to per-chunk"
);
for text in &texts {
let single = embedder.embed_batch(&[text]).await?;
out.push(single.into_iter().next().ok_or_else(|| {
LunarisError::Storage(StorageError::Backend(
"embed_batch returned 0 rows for single input".into(),
))
})?);
}
}
}
}
Ok(out)
}
pub(crate) async fn publish_needs_review(
storage: &dyn StoragePort,
scope: &lunaris_core::Scope,
items: &[NeedsReviewItem],
) {
if !storage.capabilities().queue_native {
tracing::debug!("verify queue unavailable; skipping verify-queue publish");
return;
}
for item in items {
let envelope = needs_review_envelope(item);
let payload = match serde_json::to_vec(&envelope) {
Ok(b) => b,
Err(e) => {
tracing::warn!(err = %e, "needs_review serialize failed; skipping verify-queue publish");
continue;
}
};
if let Err(e) = storage.publish(scope, VERIFY_QUEUE_TOPIC, 0, payload.into()).await {
tracing::warn!(err = %e, "verify-queue publish failed; ingest still succeeded");
}
}
}
fn needs_review_envelope(item: &NeedsReviewItem) -> serde_json::Value {
match item {
NeedsReviewItem::Entity { reason, raw } => json!({
"kind": "entity",
"item": { "reason": reason, "raw": raw },
}),
NeedsReviewItem::Relation { reason, raw } => json!({
"kind": "relation",
"item": { "reason": reason, "raw": raw },
}),
NeedsReviewItem::Fact { reason, raw } => json!({
"kind": "fact",
"item": { "reason": reason, "raw": raw },
}),
}
}
#[cfg(test)]
mod graph_granularity_tests {
use super::{build_extract_inputs, parse_graph_extract_granularity};
use lunaris_core::{Chunk, HlcClock, Scope};
use ulid::Ulid;
fn chunk(text: &str) -> Chunk {
let clock = HlcClock::new(1);
Chunk::new(Scope::dev(), Ulid::new(), text, 10, 0, vec!["h".into()], &clock)
}
#[test]
fn parse_granularity_maps_session_aliases_to_true_and_defaults_to_chunk() {
assert!(parse_graph_extract_granularity(Some("session")));
assert!(parse_graph_extract_granularity(Some(" Episode ")));
assert!(parse_graph_extract_granularity(Some("DOC")));
assert!(!parse_graph_extract_granularity(Some("chunk")));
assert!(!parse_graph_extract_granularity(Some("chunks")));
assert!(!parse_graph_extract_granularity(Some("")));
assert!(!parse_graph_extract_granularity(None));
assert!(!parse_graph_extract_granularity(Some("nonsense")));
}
#[test]
fn per_session_yields_one_whole_session_input_not_per_chunk() {
let eid = Ulid::new();
let content = "user: hi\n\nassistant: hello\n\nuser: bye";
let chunks = vec![chunk("user: hi"), chunk("assistant: hello"), chunk("user: bye")];
let inputs = build_extract_inputs(eid, content, &chunks, true, Some("2023-05-30"));
assert_eq!(inputs.len(), 1, "per-session must emit exactly one extractor input");
assert_eq!(inputs[0].chunk_id, eid, "the single input is keyed by episode id");
assert_eq!(inputs[0].text, content, "the single input carries the whole session text");
assert!(inputs[0].heading_path.is_empty());
assert_eq!(inputs[0].reference_time_iso.as_deref(), Some("2023-05-30"));
}
#[test]
fn per_session_empty_content_skips_extraction() {
let inputs = build_extract_inputs(Ulid::new(), "", &[], true, None);
assert!(inputs.is_empty(), "empty episode content must skip the extractor");
}
#[test]
fn per_chunk_yields_one_input_per_chunk_preserving_ids_and_text() {
let chunks = vec![chunk("alpha"), chunk("beta"), chunk("gamma")];
let inputs = build_extract_inputs(
Ulid::new(),
"alpha beta gamma",
&chunks,
false,
Some("2023-05-30"),
);
assert_eq!(inputs.len(), 3, "per-chunk must emit one input per chunk");
for (inp, ch) in inputs.iter().zip(chunks.iter()) {
assert_eq!(inp.chunk_id, ch.id);
assert_eq!(inp.text, ch.text);
assert_eq!(inp.heading_path, ch.heading_path);
assert_eq!(
inp.reference_time_iso.as_deref(),
Some("2023-05-30"),
"EVERY per-chunk input carries the episode reference time"
);
}
}
}