Skip to main content

lunaris_extract/
noop.rs

1//! [`NoopExtractor`] โ€” passthrough that emits empty extractions.
2//!
3//! Used as the default when:
4//!
5//! - The graph pipeline is OFF (Plan 03-03; default per blueprint ยง5.2 + D-11),
6//!   in which case `Lunaris::ingest` short-circuits before calling `extract`
7//!   anyway. The Noop seam keeps the trait-object slot full so the umbrella
8//!   handle's invariant `extractor: Arc<dyn Extractor>` always holds.
9//! - No remote provider is configured (`LUNARIS_EXTRACT_PROVIDER` unset โ€”
10//!   `Lunaris::open()` substitutes
11//!   `Arc::new(NoopExtractor)` with `tracing::warn!`). Mirrors the Plan 02-03
12//!   `BgeRerankerV2M3 โ†’ NoopReranker` cache-miss pattern.
13//!
14//! `applies()` returns `false` so callers (Plan 03-03 ingest fan-out) can
15//! short-circuit before walking the WriteOp builder loop.
16
17use async_trait::async_trait;
18use lunaris_core::LunarisError;
19use ulid::Ulid;
20
21use crate::Extractor;
22use crate::types::{ChunkInput, RawExtraction, RawExtractionBatch};
23
24/// Default extractor when graph pipeline is OFF (Plan 03-03) or when no remote
25/// backend cache is missing. Returns one empty [`RawExtraction`] per input
26/// chunk so the per-chunk index alignment downstream still holds.
27#[derive(Debug, Clone, Copy, Default)]
28pub struct NoopExtractor;
29
30#[async_trait]
31impl Extractor for NoopExtractor {
32    async fn extract(
33        &self,
34        _episode_id: Ulid,
35        chunks: &[ChunkInput],
36    ) -> Result<RawExtractionBatch, LunarisError> {
37        Ok(RawExtractionBatch {
38            by_chunk: chunks
39                .iter()
40                .map(|c| RawExtraction {
41                    source_chunk_id: c.chunk_id,
42                    entities: Vec::new(),
43                    relations: Vec::new(),
44                    facts: Vec::new(),
45                })
46                .collect(),
47        })
48    }
49
50    fn applies(&self) -> bool {
51        false
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[tokio::test]
60    async fn noop_returns_empty_extractions() {
61        let chunks = vec![
62            ChunkInput {
63                chunk_id: Ulid::new(),
64                text: "hello world".into(),
65                heading_path: vec!["intro".into()],
66                reference_time_iso: None,
67            },
68            ChunkInput {
69                chunk_id: Ulid::new(),
70                text: "second chunk".into(),
71                heading_path: vec![],
72                reference_time_iso: None,
73            },
74        ];
75        let extracted = NoopExtractor.extract(Ulid::new(), &chunks).await.unwrap();
76        assert_eq!(extracted.by_chunk.len(), 2);
77        for (i, r) in extracted.by_chunk.iter().enumerate() {
78            assert_eq!(r.source_chunk_id, chunks[i].chunk_id);
79            assert!(r.entities.is_empty());
80            assert!(r.relations.is_empty());
81            assert!(r.facts.is_empty());
82        }
83    }
84
85    #[tokio::test]
86    async fn noop_handles_empty_input_batch() {
87        let extracted = NoopExtractor.extract(Ulid::new(), &[]).await.unwrap();
88        assert!(extracted.by_chunk.is_empty());
89    }
90
91    #[test]
92    fn noop_applies_is_false() {
93        assert!(!NoopExtractor.applies(), "NoopExtractor::applies must be false");
94    }
95}