lunaris/structured_ingest.rs
1//! Phase 23 — agent-facing structured ingest.
2//!
3//! Lets an AI agent (or any caller that already knows the entity / relation
4//! structure of a message) bypass the LLM extractor and write the graph
5//! directly while still riding the same INGEST-04 single-`atomic_write`
6//! invariant and the same deterministic `EntityId = blake3(name+type)[..16]`
7//! dedup as the extractor-produced path.
8//!
9//! # Why this exists
10//!
11//! Many agents already produce structured `{entities, relations, facts}` as
12//! a side-effect of their own reasoning. Round-tripping that knowledge as
13//! prose through Lunaris's GBNF-constrained extractor pays two LLM passes
14//! (extract here + verify downstream) and loses fidelity. This entry point
15//! takes the structured payload directly.
16//!
17//! # Determinism = no lookup
18//!
19//! Because [`EntityId`] is the 16-byte truncation of
20//! `blake3(normalize(canonical_name) || "::" || entity_type)`, an agent
21//! that ingests
22//!
23//! ```text
24//! RelationInput { subject_name: "Alice", subject_type: "Person",
25//! predicate: "reports_to",
26//! object_name: "Bob", object_type: "Person", ... }
27//! ```
28//!
29//! produces the **same** subject/object EntityIds whether the underlying
30//! `Alice (Person)` node was created earlier by an LLM-extracted ingest,
31//! a prior structured ingest, or this very call. The graph storage layer
32//! dedups by key, so re-asserting an existing entity is a no-op and the
33//! new edge attaches to the existing node — no GET-then-PUT round trip,
34//! no race window.
35//!
36//! # Toggle gating
37//!
38//! Unlike the LLM extractor path, [`StructuredIngest`] **always** writes
39//! the graph regardless of `LUNARIS_GRAPH_ENABLED` / `graph_pipeline()
40//! .is_enabled()`. Rationale: the agent explicitly supplied entities —
41//! they are not best-effort extraction. The pipeline toggle continues to
42//! gate ONLY the LLM-extractor branch.
43//!
44//! # What this writes
45//!
46//! In one `atomic_write` per call:
47//!
48//! - Episode KV row (same shape as text ingest).
49//! - Per-chunk KV + `VectorUpsert` (text chunked + embedded just like the
50//! text-ingest path; BM25 indexing piggybacks on the `content` metadata
51//! field).
52//! - Per-entity `GraphNode` + `VectorUpsert{entities}`. The entity vector
53//! uses the caller's optional [`EntityInput::embedding`] when supplied;
54//! otherwise the handle's current `Embedder` embeds the entity name.
55//! - Per-relation `GraphEdge` with `source_episode_id` stamped into the
56//! props.
57//! - Per-fact KV + `VectorUpsert{facts}` (fact text embedded via the
58//! handle's `Embedder`).
59//!
60//! Provenance carried on edges/facts is **episode-level only** in v0.3:
61//! `source_episode_id`. Per-chunk attribution (`source_chunk_id`) and
62//! chunk-MENTIONS-entity edges land in a follow-up phase.
63
64use chrono::{DateTime, Utc};
65use serde::{Deserialize, Serialize};
66use serde_json::json;
67use ulid::Ulid;
68
69use std::collections::HashMap;
70
71use lunaris_core::keyspace::{chunk_key, episode_key, fact_key as scoped_fact_key, fact_spo_key};
72use lunaris_core::{
73 Chunk, Embedder, Hlc, HlcClock, Lsn, LunarisError, Scope, StorageError, StoragePort, WriteOp,
74 sanitize_graph_ident,
75};
76use lunaris_extract::types::{EntityId, Fact, FactId};
77use lunaris_extract::validator::{NeedsReviewItem, NeedsReviewReason};
78use lunaris_ingest::chunk_markdown;
79
80use crate::episode_builder::EpisodeBuilder;
81use crate::reconcile::{FactDecision, FactTriple, SpoEntry, classify_fact};
82
83// Index names + graph name kept in sync with the LLM-extracted path in
84// `crate::ingest`. Same string constants, kept private to this module so a
85// refactor that moves them to a shared place can flip both call sites
86// together.
87const CHUNK_VECTOR_INDEX: &str = "chunks";
88const ENTITIES_INDEX: &str = "entities";
89const FACTS_INDEX: &str = "facts";
90const GRAPH_NAME: &str = "lunaris_graph";
91
92// Mirrors `lunaris_ingest::pipeline::DEFAULT_TARGET_TOKENS` /
93// `DEFAULT_OVERLAP_TOKENS`. Inlined to avoid widening the
94// `lunaris_ingest::pipeline` public surface; both numbers are stable across
95// the chunker contract.
96const DEFAULT_TARGET_TOKENS: usize = 256;
97const DEFAULT_OVERLAP_TOKENS: usize = 32;
98
99/// Default confidence for agent-supplied items. Agents that omit
100/// confidence are taken at their word — they presumably know what they
101/// asserted.
102fn default_confidence() -> f32 {
103 1.0
104}
105
106/// Agent-supplied entity. See module docs for the EntityId derivation
107/// contract — the `(name, entity_type)` pair is the source of truth for
108/// node identity; supplying a different alias for the same entity does
109/// **not** create a new node.
110#[derive(Clone, Debug, Serialize, Deserialize)]
111pub struct EntityInput {
112 pub name: String,
113 pub entity_type: String,
114 #[serde(default)]
115 pub aliases: Vec<String>,
116 #[serde(default = "default_confidence")]
117 pub confidence: f32,
118 pub valid_from: DateTime<Utc>,
119 #[serde(default)]
120 pub valid_to: Option<DateTime<Utc>>,
121 /// Optional caller-supplied entity embedding. When `Some`, MUST match
122 /// the handle's [`Embedder::dim`] — a mismatch surfaces as a
123 /// `StorageError::Backend` at ingest time so the operator can correct
124 /// the wheel build rather than silently corrupting the vector index.
125 /// When `None`, Lunaris embeds [`Self::name`] via the handle's
126 /// `Embedder`.
127 #[serde(default)]
128 pub embedding: Option<Vec<f32>>,
129}
130
131/// Agent-supplied relation. Both endpoints are addressed by
132/// `(name, entity_type)` — see module docs.
133#[derive(Clone, Debug, Serialize, Deserialize)]
134pub struct RelationInput {
135 pub subject_name: String,
136 pub subject_type: String,
137 pub predicate: String,
138 pub object_name: String,
139 pub object_type: String,
140 #[serde(default = "default_confidence")]
141 pub confidence: f32,
142 pub valid_from: DateTime<Utc>,
143 #[serde(default)]
144 pub valid_to: Option<DateTime<Utc>>,
145}
146
147/// Agent-supplied fact. `fact_text` is the natural-language rendering of
148/// the `(subject, predicate, object)` triple; it is what gets embedded for
149/// fact vector recall.
150#[derive(Clone, Debug, Serialize, Deserialize)]
151pub struct FactInput {
152 pub fact_text: String,
153 pub subject_name: String,
154 pub subject_type: String,
155 pub predicate: String,
156 pub object_name: String,
157 pub object_type: String,
158 #[serde(default = "default_confidence")]
159 pub confidence: f32,
160 pub valid_from: DateTime<Utc>,
161 #[serde(default)]
162 pub valid_to: Option<DateTime<Utc>>,
163}
164
165/// Top-level payload for [`crate::Lunaris::ingest_structured`] /
166/// [`crate::ScopedLunaris::ingest_structured`].
167///
168/// `episode` carries the conversation-turn text (chunked + embedded like
169/// the text-ingest path); the three vectors carry the agent's structured
170/// knowledge. Any subset may be empty — an episode with only entities,
171/// only relations, or no graph payload at all is valid (the latter
172/// degenerates to a vanilla text ingest with the graph pipeline off).
173pub struct StructuredIngest {
174 pub episode: EpisodeBuilder,
175 pub entities: Vec<EntityInput>,
176 pub relations: Vec<RelationInput>,
177 pub facts: Vec<FactInput>,
178}
179
180impl StructuredIngest {
181 /// Construct a structured-ingest payload from an episode builder. All
182 /// three structured lists start empty — chain `.with_entities(...)`
183 /// etc. to populate.
184 #[must_use]
185 pub fn new(episode: EpisodeBuilder) -> Self {
186 Self { episode, entities: Vec::new(), relations: Vec::new(), facts: Vec::new() }
187 }
188
189 #[must_use]
190 pub fn with_entities(mut self, entities: Vec<EntityInput>) -> Self {
191 self.entities = entities;
192 self
193 }
194
195 #[must_use]
196 pub fn with_relations(mut self, relations: Vec<RelationInput>) -> Self {
197 self.relations = relations;
198 self
199 }
200
201 #[must_use]
202 pub fn with_facts(mut self, facts: Vec<FactInput>) -> Self {
203 self.facts = facts;
204 self
205 }
206}
207
208/// Internal implementation. The public surface is
209/// [`crate::Lunaris::ingest_structured`] /
210/// [`crate::ScopedLunaris::ingest_structured`] which inject the storage,
211/// embedder, and clock from the handle.
212///
213/// INGEST-04 invariant preserved: exactly ONE `atomic_write` call covers
214/// all writes (episode KV + per-chunk KV/Vector + per-entity
215/// GraphNode/Vector + per-relation GraphEdge + per-fact KV/Vector).
216///
217/// Exposed as `#[doc(hidden)] pub` (not part of the stable surface) so the
218/// `memory-update-intelligence` integration tests can drive the REAL
219/// production write path against a recording `StoragePort` double — proving
220/// the dedup + cross-episode-publish logic is actually WIRED into ingest, not
221/// merely unit-correct in isolation. Production callers go through the handle.
222#[doc(hidden)]
223pub async fn ingest_structured_inner(
224 storage: &dyn StoragePort,
225 embedder: &dyn Embedder,
226 clock: &HlcClock,
227 payload: StructuredIngest,
228 scope: Scope,
229) -> Result<Lsn, LunarisError> {
230 let episode = payload.episode.into_episode(scope, clock);
231 let embedder_dim = embedder.dim();
232
233 // ── 1. Chunk + embed episode text ───────────────────────────────────
234 let drafts = chunk_markdown(&episode.content, DEFAULT_TARGET_TOKENS, DEFAULT_OVERLAP_TOKENS);
235 let chunk_embeddings: Vec<Vec<f32>> = if drafts.is_empty() {
236 Vec::new()
237 } else {
238 let texts: Vec<&str> = drafts.iter().map(|d| d.text.as_str()).collect();
239 let rows = embedder.embed_batch(&texts).await?;
240 if rows.len() != texts.len() {
241 return Err(LunarisError::Storage(StorageError::Backend(format!(
242 "structured_ingest: chunk embed returned {} rows for {} chunks",
243 rows.len(),
244 texts.len()
245 ))));
246 }
247 rows
248 };
249
250 // ── 2. Resolve entity embeddings ────────────────────────────────────
251 // Caller-supplied entries are dim-validated up front; the rest go in
252 // a single embed_batch call indexed by `to_embed_idx` so the order is
253 // preserved when we splice results back.
254 let mut entity_embeds: Vec<Vec<f32>> = vec![Vec::new(); payload.entities.len()];
255 let mut to_embed_idx: Vec<usize> = Vec::new();
256 let mut to_embed_text: Vec<String> = Vec::new();
257 for (i, e) in payload.entities.iter().enumerate() {
258 if let Some(emb) = &e.embedding {
259 if emb.len() != embedder_dim {
260 return Err(LunarisError::Storage(StorageError::Backend(format!(
261 "structured_ingest: EntityInput {:?} supplied embedding has dim {} but \
262 handle expects {}",
263 e.name,
264 emb.len(),
265 embedder_dim
266 ))));
267 }
268 entity_embeds[i] = emb.clone();
269 } else {
270 to_embed_idx.push(i);
271 to_embed_text.push(e.name.clone());
272 }
273 }
274 if !to_embed_text.is_empty() {
275 let texts: Vec<&str> = to_embed_text.iter().map(String::as_str).collect();
276 let rows = embedder.embed_batch(&texts).await?;
277 if rows.len() != to_embed_idx.len() {
278 return Err(LunarisError::Storage(StorageError::Backend(format!(
279 "structured_ingest: entity embed returned {} rows for {} entities",
280 rows.len(),
281 to_embed_idx.len()
282 ))));
283 }
284 for (idx, emb) in to_embed_idx.into_iter().zip(rows.into_iter()) {
285 entity_embeds[idx] = emb;
286 }
287 }
288
289 // ── 3. Embed fact text in a single batch ────────────────────────────
290 let fact_embeds: Vec<Vec<f32>> = if payload.facts.is_empty() {
291 Vec::new()
292 } else {
293 let texts: Vec<&str> = payload.facts.iter().map(|f| f.fact_text.as_str()).collect();
294 let rows = embedder.embed_batch(&texts).await?;
295 if rows.len() != texts.len() {
296 return Err(LunarisError::Storage(StorageError::Backend(format!(
297 "structured_ingest: fact embed returned {} rows for {} facts",
298 rows.len(),
299 texts.len()
300 ))));
301 }
302 rows
303 };
304
305 // ── 4. Assemble Vec<WriteOp> ────────────────────────────────────────
306 let mut ops: Vec<WriteOp> = Vec::with_capacity(
307 1 + 2 * drafts.len()
308 + 2 * payload.entities.len()
309 + payload.relations.len()
310 + 2 * payload.facts.len(),
311 );
312
313 // Episode KV.
314 let episode_value = serde_json::to_vec(&episode).map_err(|e| {
315 LunarisError::Storage(StorageError::Backend(format!(
316 "structured_ingest: episode serialize: {e}"
317 )))
318 })?;
319 ops.push(WriteOp::KvPut { key: episode_key(&episode.scope, episode.id), value: episode_value });
320
321 // Per-chunk KV + Vector (BM25 piggybacks on `content` in metadata).
322 let mut chunks: Vec<Chunk> = Vec::with_capacity(drafts.len());
323 for (draft, emb) in drafts.into_iter().zip(chunk_embeddings.into_iter()) {
324 let mut c = draft.into_chunk_valid_from(
325 episode.scope.clone(),
326 episode.id,
327 clock,
328 episode.bt.valid.0,
329 );
330 c.embedding = Some(emb.clone());
331 let chunk_value = serde_json::to_vec(&c).map_err(|e| {
332 LunarisError::Storage(StorageError::Backend(format!(
333 "structured_ingest: chunk serialize: {e}"
334 )))
335 })?;
336 ops.push(WriteOp::KvPut { key: chunk_key(&episode.scope, c.id), value: chunk_value });
337 ops.push(WriteOp::VectorUpsert {
338 index: CHUNK_VECTOR_INDEX.into(),
339 id: c.id.to_bytes().to_vec(),
340 embedding: emb,
341 metadata: json!({
342 "episode_id": c.episode_id.to_string(),
343 "heading_path": c.heading_path,
344 "offset": c.offset,
345 "text": c.text,
346 "source": &episode.source,
347 }),
348 });
349 chunks.push(c);
350 }
351
352 // Per-entity GraphNode + VectorUpsert. EntityId is deterministic so
353 // re-ingesting an existing logical entity collapses onto the existing
354 // node at the storage-key layer.
355 let episode_id_str = episode.id.to_string();
356 for (e, emb) in payload.entities.iter().zip(entity_embeds.iter()) {
357 let eid = EntityId::from_name_and_type(&e.name, &e.entity_type);
358 let id_bytes = eid.0.to_vec();
359 ops.push(WriteOp::GraphNode {
360 graph: GRAPH_NAME.into(),
361 id: id_bytes.clone(),
362 // T-01-03-01: agent-supplied entity_type is untrusted free-form
363 // text, same Cypher-injection/parse-break risk as extractor
364 // output in crate::ingest. See sanitize_graph_ident doc.
365 label: sanitize_graph_ident(&e.entity_type, "Entity"),
366 props: json!({
367 "id_hex": format!("{eid}"),
368 "name": e.name,
369 "type": e.entity_type,
370 "aliases": e.aliases,
371 "confidence": e.confidence,
372 "valid_from_iso": e.valid_from.to_rfc3339(),
373 "valid_to_iso": e.valid_to.map(|t| t.to_rfc3339()),
374 "source_episode_id": episode_id_str,
375 }),
376 index_kind: "entities".into(),
377 });
378 ops.push(WriteOp::VectorUpsert {
379 index: ENTITIES_INDEX.into(),
380 id: id_bytes,
381 embedding: emb.clone(),
382 metadata: json!({"entity_type": e.entity_type, "name": e.name}),
383 });
384 }
385
386 // Per-relation GraphEdge with episode-level provenance.
387 for r in &payload.relations {
388 let sid = EntityId::from_name_and_type(&r.subject_name, &r.subject_type);
389 let oid = EntityId::from_name_and_type(&r.object_name, &r.object_type);
390 ops.push(WriteOp::GraphEdge {
391 graph: GRAPH_NAME.into(),
392 src: sid.0.to_vec(),
393 dst: oid.0.to_vec(),
394 // T-01-03-01: same rationale as the GraphNode label above.
395 rel: sanitize_graph_ident(&r.predicate, "RELATED_TO"),
396 props: json!({
397 "confidence": r.confidence,
398 "valid_from_iso": r.valid_from.to_rfc3339(),
399 "valid_to_iso": r.valid_to.map(|t| t.to_rfc3339()),
400 "source_episode_id": episode_id_str,
401 }),
402 });
403 }
404
405 // Per-fact KV + VectorUpsert, with memory-update convergence:
406 // - SYNC dedup: the fact id is the deterministic `FactId` of the
407 // (subject, predicate, object) triple, so re-asserting an identical
408 // fact overwrites the same row in place (no duplicate accrues).
409 // - CROSS-EPISODE contradiction detection: each fact is classified
410 // against the in-scope `(subject, predicate)` spo-index read once at
411 // `now`; an overlapping different-object assertion is collected as a
412 // `NeedsReviewItem` and published to the async verify queue AFTER the
413 // commit (the verifier closes the loser via `apply_supersede`).
414 // The spo-index updates are folded into the SAME `ops` vec below, so the
415 // single-`atomic_write` (INGEST-04) invariant holds. Reads do not count.
416 let now_hlc = clock.tick();
417 // spo-key → running entries (seeded from storage on first touch this call,
418 // then mutated as additive/supersede facts are appended so multiple facts
419 // sharing a (subject, predicate) in the SAME payload see each other).
420 let mut spo_index: HashMap<Vec<u8>, Vec<SpoEntry>> = HashMap::new();
421 let mut needs_review: Vec<NeedsReviewItem> = Vec::new();
422
423 for (f, emb) in payload.facts.iter().zip(fact_embeds.iter()) {
424 let sid = EntityId::from_name_and_type(&f.subject_name, &f.subject_type);
425 let oid = EntityId::from_name_and_type(&f.object_name, &f.object_type);
426 // Deterministic identity = sync dedup key.
427 let fact_id = Ulid::from_bytes(FactId::from_triple(sid, &f.predicate, oid).0);
428
429 // Seed the spo-index for this (subject, predicate) from storage once.
430 let spo_key = fact_spo_key(&episode.scope, &sid.0, &f.predicate);
431 if !spo_index.contains_key(&spo_key) {
432 let prior = read_spo_index(storage, &episode.scope, &spo_key, now_hlc).await?;
433 spo_index.insert(spo_key.clone(), prior);
434 }
435
436 let new_triple = FactTriple {
437 subject_id: sid,
438 predicate: f.predicate.clone(),
439 object_id: oid,
440 valid_from: f.valid_from,
441 valid_to: f.valid_to,
442 };
443 let prior = &spo_index[&spo_key];
444 match classify_fact(&new_triple, prior) {
445 FactDecision::Noop => {
446 // Exact re-assertion → dedup: the deterministic-id KvPut
447 // overwrites the fact row IN PLACE with the new window, so keep
448 // the matching spo-index entry's window in sync. Otherwise a
449 // later cross-episode check classifies against a STALE interval
450 // and can falsely supersede (BUG-1 / no_false_supersede).
451 if let Some(entry) = spo_index
452 .get_mut(&spo_key)
453 .and_then(|v| v.iter_mut().find(|e| e.object_id == oid))
454 {
455 entry.valid_from = f.valid_from;
456 entry.valid_to = f.valid_to;
457 }
458 }
459 FactDecision::Append => {
460 spo_index.get_mut(&spo_key).expect("seeded above").push(SpoEntry {
461 object_id: oid,
462 fact_id,
463 valid_from: f.valid_from,
464 valid_to: f.valid_to,
465 });
466 }
467 FactDecision::Supersede { loser_fact_id } => {
468 // The new fact is still written + indexed (additive); the
469 // verifier closes the loser asynchronously.
470 let existing_object =
471 prior.iter().find(|p| p.fact_id == loser_fact_id).map_or(oid, |p| p.object_id);
472 needs_review.push(NeedsReviewItem::Fact {
473 reason: NeedsReviewReason::CrossEpisodeContradiction {
474 subject: sid,
475 predicate: f.predicate.clone(),
476 existing_fact_id: loser_fact_id,
477 existing_object,
478 new_fact_id: fact_id,
479 new_object: oid,
480 },
481 raw: Fact {
482 id: fact_id,
483 subject_id: sid,
484 predicate: f.predicate.clone(),
485 object_id: oid,
486 fact_text: f.fact_text.clone(),
487 confidence: f.confidence,
488 valid_from_iso: f.valid_from.to_rfc3339(),
489 valid_to_iso: f.valid_to.map(|t| t.to_rfc3339()),
490 },
491 });
492 spo_index.get_mut(&spo_key).expect("seeded above").push(SpoEntry {
493 object_id: oid,
494 fact_id,
495 valid_from: f.valid_from,
496 valid_to: f.valid_to,
497 });
498 }
499 }
500
501 let fact_value = serde_json::to_vec(&serde_json::json!({
502 "id": fact_id.to_string(),
503 "subject_id": sid.0,
504 "predicate": f.predicate,
505 "object_id": oid.0,
506 "fact_text": f.fact_text,
507 "confidence": f.confidence,
508 "valid_from_iso": f.valid_from.to_rfc3339(),
509 "valid_to_iso": f.valid_to.map(|t| t.to_rfc3339()),
510 "source_episode_id": episode_id_str,
511 }))
512 .map_err(|e| {
513 LunarisError::Storage(StorageError::Backend(format!(
514 "structured_ingest: fact serialize: {e}"
515 )))
516 })?;
517 ops.push(WriteOp::KvPut {
518 key: scoped_fact_key(&episode.scope, fact_id),
519 value: fact_value,
520 });
521 ops.push(WriteOp::VectorUpsert {
522 index: FACTS_INDEX.into(),
523 id: fact_id.to_bytes().to_vec(),
524 embedding: emb.clone(),
525 metadata: json!({"predicate": f.predicate, "fact_text": f.fact_text}),
526 });
527 // F16: the fact must exist as a GRAPH node too, not only in KV and the
528 // vector index. Without this the agent-supplied path writes a graph of
529 // entities with nothing retrievable in it: `Graph::anchored` returns
530 // node ids and `hydrate_mixed` resolves a candidate as a chunk row or a
531 // fact row — never an entity row — so a traversal that reaches only
532 // entities yields no hit at all. Mirrors the extraction path's fan-out
533 // in `ingest.rs` (Fact node + HAS_FACT + FACT_ABOUT) so both ingest
534 // paths leave the same graph shape behind.
535 let fact_id_bytes = fact_id.to_bytes().to_vec();
536 ops.push(WriteOp::GraphNode {
537 graph: GRAPH_NAME.into(),
538 id: fact_id_bytes.clone(),
539 label: "Fact".into(),
540 props: json!({
541 // `id_hex` is the property the retrieval Cypher selects
542 // (`RETURN m.id_hex`). A Fact node without it comes back NULL
543 // and its candidate is dropped — see the F16 RED commit.
544 "id_hex": fact_id_bytes.iter().map(|b| format!("{b:02x}")).collect::<String>(),
545 "predicate": f.predicate,
546 "confidence": f.confidence,
547 "valid_from_iso": f.valid_from.to_rfc3339(),
548 "valid_to_iso": f.valid_to.map(|t| t.to_rfc3339()),
549 }),
550 index_kind: "facts".into(),
551 });
552 ops.push(WriteOp::GraphEdge {
553 graph: GRAPH_NAME.into(),
554 src: sid.0.to_vec(),
555 dst: fact_id_bytes.clone(),
556 rel: "HAS_FACT".into(),
557 props: json!({}),
558 });
559 ops.push(WriteOp::GraphEdge {
560 graph: GRAPH_NAME.into(),
561 src: fact_id_bytes,
562 dst: oid.0.to_vec(),
563 rel: "FACT_ABOUT".into(),
564 props: json!({}),
565 });
566 }
567
568 // Fold the updated spo-index rows into the SAME atomic_write (one KvPut per
569 // touched (subject, predicate) — INGEST-04 single-write preserved).
570 for (key, entries) in &spo_index {
571 let value = serde_json::to_vec(&spo_entries_to_json(entries)).map_err(|e| {
572 LunarisError::Storage(StorageError::Backend(format!(
573 "structured_ingest: spo-index serialize: {e}"
574 )))
575 })?;
576 ops.push(WriteOp::KvPut { key: key.clone(), value });
577 }
578
579 // ── 5. Single atomic_write (INGEST-04 invariant) ────────────────────
580 let lsn = storage.atomic_write(&episode.scope, &ops).await?;
581
582 // ── 6. Post-commit: publish cross-episode contradictions to the verify
583 // queue (side channel; the ingest already committed atomically).
584 if !needs_review.is_empty() {
585 crate::ingest::publish_needs_review(storage, &episode.scope, &needs_review).await;
586 }
587
588 Ok(lsn)
589}
590
591/// Read + parse the `(subject, predicate)` spo-index row at `as_of` into the
592/// prior [`SpoEntry`] list consumed by [`classify_fact`]. A missing row (or an
593/// empty/garbled value) yields an empty list — the first fact for a
594/// `(subject, predicate)` is always additive.
595/// `pub(crate)` so the LLM-extraction path (`ingest::ingest_episode_graph_on`)
596/// reuses this exact reader instead of forking the row format — the two
597/// ingest paths MUST agree on the spo-index encoding or a fact written by one
598/// is invisible to the other's contradiction check.
599pub(crate) async fn read_spo_index(
600 storage: &dyn StoragePort,
601 scope: &Scope,
602 key: &[u8],
603 as_of: Hlc,
604) -> Result<Vec<SpoEntry>, LunarisError> {
605 let Some(row) = storage.read_as_of(scope, key, as_of).await.map_err(LunarisError::Storage)?
606 else {
607 return Ok(Vec::new());
608 };
609 let arr: Vec<serde_json::Value> = serde_json::from_slice(&row.value).unwrap_or_default();
610 let mut out = Vec::with_capacity(arr.len());
611 for v in arr {
612 let (Some(obj_hex), Some(fid_str), Some(vf_str)) = (
613 v.get("object_id").and_then(|x| x.as_str()),
614 v.get("fact_id").and_then(|x| x.as_str()),
615 v.get("valid_from").and_then(|x| x.as_str()),
616 ) else {
617 continue;
618 };
619 let (Some(object_id), Some(fact_id), Some(valid_from)) = (
620 EntityId::from_hex(obj_hex),
621 Ulid::from_string(fid_str).ok(),
622 DateTime::parse_from_rfc3339(vf_str).ok().map(|d| d.with_timezone(&Utc)),
623 ) else {
624 continue;
625 };
626 let valid_to = v
627 .get("valid_to")
628 .and_then(|x| x.as_str())
629 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
630 .map(|d| d.with_timezone(&Utc));
631 out.push(SpoEntry { object_id, fact_id, valid_from, valid_to });
632 }
633 Ok(out)
634}
635
636/// Serialize the spo-index entries to the canonical JSON array shape:
637/// `[{object_id:hex, fact_id:ulid_str, valid_from:iso, valid_to:iso|null}]`.
638pub(crate) fn spo_entries_to_json(entries: &[SpoEntry]) -> Vec<serde_json::Value> {
639 entries
640 .iter()
641 .map(|e| {
642 json!({
643 "object_id": format!("{}", e.object_id),
644 "fact_id": e.fact_id.to_string(),
645 "valid_from": e.valid_from.to_rfc3339(),
646 "valid_to": e.valid_to.map(|t| t.to_rfc3339()),
647 })
648 })
649 .collect()
650}