lunaris_extract/lib.rs
1//! lunaris-extract — Phase 3 entity + relation + fact extractor (default-OFF
2//! per blueprint §5.2).
3//!
4//! Extraction is REMOTE-ONLY since the llama.cpp-only cutover (2026-07,
5//! Phase C). Two feature-gated backends behind one dyn-compatible
6//! [`Extractor`] trait:
7//!
8//! - `OllamaExtractor` (`feature = "ollama"`) — POSTs `/api/chat` with the
9//! GBNF grammar translated to a JSON schema in the `format` field.
10//! - `CloudApiExtractor` (`feature = "cloud-api"`) — provider-mux Anthropic /
11//! OpenAI / Gemini / MiniMax / OpenAI-compatible URL selectable via
12//! `LUNARIS_EXTRACT_PROVIDER` env (D-01). Per
13//! D-21: single retry on transient errors then emit a sentinel entity that
14//! the [`validator::validate`] pass routes to
15//! `NeedsReviewReason::TransientAfterRetry`.
16//!
17//! ## Default-OFF contract (blueprint §5.2 + D-11)
18//!
19//! The umbrella `Lunaris` handle keeps the extractor under a
20//! `GraphPipelineHandle` toggle (Plan 03-03). With the toggle OFF the extractor
21//! is dead code: no HTTP client constructed, no extra threads spawned.
22//! Zero-config `open()` resolves [`NoopExtractor`] (degraded mode) — a real
23//! backend requires `LUNARIS_EXTRACT_PROVIDER` (+ provider envs) or an
24//! explicit `with_extractor` call.
25//!
26//! ## EntityId derivation (D-06)
27//!
28//! [`EntityId`] is the deterministic 16-byte truncation of
29//! `blake3(canonical_name_normalized || "::" || entity_type)`. Stable across
30//! re-ingest, across chunks within an Episode, across Episodes. No second-pass
31//! dedupe round trip. See [`types::EntityId::from_name_and_type`].
32//!
33//! ## Validator (D-08)
34//!
35//! [`validator::validate`] walks a [`RawExtractionBatch`] and routes invalid
36//! items to [`validator::ValidatedExtraction::needs_review`] with one of four
37//! structured reasons:
38//!
39//! 1. [`NeedsReviewReason::InvalidBitemporal`] — `valid_from >= valid_to`
40//! 2. [`NeedsReviewReason::StructuralContradiction`] — same `(subject_id,
41//! predicate)` with overlapping `[valid_from, valid_to]` and conflicting
42//! `object_id`s within the SAME Episode
43//! 3. [`NeedsReviewReason::GbnfFailure`] — parsed item violates the grammar
44//! schema (empty name / out-of-range confidence / etc.)
45//! 4. [`NeedsReviewReason::TransientAfterRetry`] — cloud-api retry exhaust
46//! (D-21 sentinel detection)
47//!
48//! Cross-Episode contradictions (e.g., two Episodes claim different birth years
49//! for Alice) are deferred to the Phase 4 Verifier worker per D-09 — out of
50//! scope here.
51//!
52//! ## Module layout
53//!
54//! - [`types`] — DTOs ([`EntityId`], [`Entity`], [`Relation`], [`Fact`],
55//! [`ChunkInput`], [`RawExtraction`], [`RawExtractionBatch`],
56//! [`ExtractionBatch`])
57//! - [`noop`] — [`NoopExtractor`] (always-empty extraction; default when
58//! graph pipeline is OFF or no provider is configured)
59//! - `ollama` (gated `ollama`) — `OllamaExtractor`
60//! - `cloud_api` (gated `cloud-api`) — `CloudApiExtractor`
61//! - [`validator`] — [`validator::validate`] + `NeedsReviewReason`
62
63#![deny(rust_2018_idioms, unreachable_pub)]
64#![forbid(unsafe_code)]
65
66use async_trait::async_trait;
67use lunaris_core::LunarisError;
68use ulid::Ulid;
69
70// Content-addressed extraction cache decorator — wraps any Extractor so a
71// given (prompt-template, model-namespace, chunk) triple hits the LLM at
72// most once. Always built (std + blake3 + serde only).
73pub mod cached;
74#[cfg(feature = "cloud-api")]
75pub mod cloud_api;
76// RFC 0007 §3 — FallbackExtractor<P, F> static-dispatch combinator with
77// per-instance CircuitBreaker. Always built; the breaker primitive lives
78// in lunaris-core::circuit_breaker.
79pub mod fallback;
80// Phase 11 — backend-agnostic adapter over `lunaris_llm::LlmBackend`.
81pub mod llm_extractor;
82pub mod noop;
83#[cfg(feature = "ollama")]
84pub mod ollama;
85pub mod types;
86pub mod validator;
87
88pub use cached::{CacheStats, CachedExtractor};
89#[cfg(feature = "cloud-api")]
90pub use cloud_api::{CloudApiExtractor, CloudApiExtractorOpts, CloudProvider};
91pub use llm_extractor::{LlmExtractor, LlmExtractorOpts};
92pub use noop::NoopExtractor;
93#[cfg(feature = "ollama")]
94pub use ollama::{OllamaExtractor, OllamaExtractorOpts};
95pub use types::{
96 ChunkInput, Entity, EntityId, ExtractionBatch, Fact, RawExtraction, RawExtractionBatch,
97 Relation,
98};
99pub use validator::{
100 NeedsReviewItem, NeedsReviewReason, ValidatedExtraction, cap_future_valid_from, into_batch,
101 validate,
102};
103
104/// Object-safe async extractor.
105///
106/// Since the llama.cpp-only cutover the shipped backends are
107/// `OllamaExtractor` (HTTP) and `CloudApiExtractor` (Anthropic / OpenAI /
108/// Gemini / MiniMax / OpenAI-compatible URL). All implementations
109/// MUST honour the per-batch timeout (D-02) by falling back to per-chunk
110/// extraction on timeout, and MUST emit either valid extractions or a sentinel
111/// recognized by [`validator::validate`] — never silently drop chunks.
112///
113/// `Arc<dyn Extractor>` is constructible (proven by the compile-time
114/// `extractor_is_dyn_compat` test), so the umbrella `Lunaris::with_extractor`
115/// builder accepts any backend without compile-time monomorphization.
116#[async_trait]
117pub trait Extractor: Send + Sync + 'static {
118 /// Extract entities + relations + facts from a batch of chunk inputs.
119 ///
120 /// Returns a [`RawExtractionBatch`] — call [`validate`] downstream to flag
121 /// NeedsReview cases (per EXTRACT-05). The Plan 03-03 ingest fan-out
122 /// converts the validated batch into `WriteOp::GraphNode` /
123 /// `WriteOp::GraphEdge` ops at the `StoragePort::atomic_write` boundary.
124 async fn extract(
125 &self,
126 episode_id: Ulid,
127 chunks: &[ChunkInput],
128 ) -> Result<RawExtractionBatch, LunarisError>;
129
130 /// Returns `true` when this extractor produces real extractions; `false`
131 /// for [`NoopExtractor`] so callers (Plan 03-03 ingest fan-out) can skip
132 /// the GraphNode / GraphEdge `WriteOp`s when `applies() == false`.
133 fn applies(&self) -> bool {
134 true
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use std::sync::Arc;
142
143 /// Compile-time proof the trait is dyn-compatible (object-safe). If a
144 /// future addition (generic method, `Self: Sized` bound) breaks this, the
145 /// `Arc<dyn Extractor>` form on the umbrella handle stops compiling.
146 #[test]
147 fn extractor_is_dyn_compat() {
148 fn _check<T: Extractor + ?Sized>() {}
149 _check::<dyn Extractor>();
150 let _: Arc<dyn Extractor> = Arc::new(NoopExtractor);
151 }
152}