Skip to main content

lunaris_extract/
fallback.rs

1//! RFC 0007 §3 — `FallbackExtractor<P, F>` static-dispatch combinator.
2//!
3//! Wraps a **primary** extractor and a **fallback** extractor. The primary
4//! drives a per-instance [`CircuitBreaker`] (from `lunaris-core`):
5//!
6//! - When the breaker is Closed / HalfOpen, the primary runs first.
7//!   Success → return primary's result. Transient failure → record failure
8//!   on the breaker and try fallback. Terminal failure → return the error
9//!   verbatim (don't mask).
10//! - When the breaker is Open, skip the primary entirely and drive
11//!   straight to the fallback.
12//!
13//! Stacks recursively. `FallbackExtractor::new(a, FallbackExtractor::new(b, c))`
14//! gives a 3-deep stack with one breaker per layer (each layer's primary
15//! has its own breaker). Cross-layer state sharing is an RFC §7 open
16//! question — pass an explicit `Arc<CircuitBreaker>` to
17//! [`FallbackExtractor::with_breaker`] for shared state.
18//!
19//! ## Transient vs terminal (RFC 0007 §3.3)
20//!
21//! Only transient errors trip the breaker AND fall through to the
22//! fallback. Terminal errors are masked-safe to propagate — if your
23//! caller's data is malformed (`GrammarReject`), retrying with a
24//! different provider won't help. The current heuristic:
25//!
26//! - **Transient**: `Storage(Backend(...))` (network / 5xx upstream),
27//!   `Extract(Timeout)`, `Extract(Backend(...))` (transport-level).
28//! - **Terminal**: `Extract(GrammarReject)` (schema-level — your
29//!   prompt is wrong), `Validate(...)` (data-level), `Storage(NotSupported)`.
30//!
31//! The classifier lives in [`is_transient`] so callers + tests can
32//! introspect the policy.
33
34use std::sync::Arc;
35
36use async_trait::async_trait;
37use ulid::Ulid;
38
39use lunaris_core::circuit_breaker::CircuitBreaker;
40use lunaris_core::{ExtractError, LunarisError, StorageError};
41
42use crate::types::ChunkInput;
43use crate::{Extractor, RawExtractionBatch};
44
45/// Opaque provider tag — used for breaker bookkeeping + tracing labels.
46/// Keep stable across releases; downstream metrics dashboards depend on
47/// these strings.
48#[derive(Clone, Debug)]
49pub struct ProviderId(String);
50
51impl ProviderId {
52    #[must_use]
53    pub fn new(s: impl Into<String>) -> Self {
54        Self(s.into())
55    }
56
57    #[must_use]
58    pub fn as_str(&self) -> &str {
59        &self.0
60    }
61}
62
63/// Two-arm static-dispatch fallback extractor.
64///
65/// `P` and `F` are concrete extractor types — *not* `dyn Extractor`.
66/// This keeps the ingest hot path monomorphised (RFC 0007 §2.2 #4).
67pub struct FallbackExtractor<P, F>
68where
69    P: Extractor,
70    F: Extractor,
71{
72    primary: P,
73    fallback: F,
74    breaker: Arc<CircuitBreaker>,
75    provider_id: ProviderId,
76}
77
78impl<P, F> FallbackExtractor<P, F>
79where
80    P: Extractor,
81    F: Extractor,
82{
83    /// Construct a fallback chain with a fresh per-instance breaker.
84    pub fn new(primary: P, fallback: F, provider_id: ProviderId) -> Self {
85        Self { primary, fallback, breaker: Arc::new(CircuitBreaker::new()), provider_id }
86    }
87
88    /// Construct with an explicit `Arc<CircuitBreaker>` so two
89    /// `FallbackExtractor` instances pointing at the same upstream can
90    /// share breaker state. RFC §7 open-question accommodation.
91    #[must_use]
92    pub fn with_breaker(mut self, breaker: Arc<CircuitBreaker>) -> Self {
93        self.breaker = breaker;
94        self
95    }
96
97    /// Borrow the breaker — primarily for metrics / observability /
98    /// tests asserting the state machine.
99    #[must_use]
100    pub fn breaker(&self) -> &Arc<CircuitBreaker> {
101        &self.breaker
102    }
103
104    #[must_use]
105    pub fn provider_id(&self) -> &ProviderId {
106        &self.provider_id
107    }
108}
109
110#[async_trait]
111impl<P, F> Extractor for FallbackExtractor<P, F>
112where
113    P: Extractor,
114    F: Extractor,
115{
116    async fn extract(
117        &self,
118        episode_id: Ulid,
119        chunks: &[ChunkInput],
120    ) -> Result<RawExtractionBatch, LunarisError> {
121        if self.breaker.allow_request() {
122            match self.primary.extract(episode_id, chunks).await {
123                Ok(batch) => {
124                    self.breaker.on_success();
125                    tracing::trace!(
126                        provider = %self.provider_id.as_str(),
127                        "fallback.primary.success"
128                    );
129                    return Ok(batch);
130                }
131                Err(e) if is_transient(&e) => {
132                    self.breaker.on_failure();
133                    tracing::warn!(
134                        provider = %self.provider_id.as_str(),
135                        error = %e,
136                        "fallback.primary.transient_failure — routing to fallback"
137                    );
138                    // fall through to fallback
139                }
140                Err(e) => {
141                    // Terminal — propagate without masking.
142                    tracing::warn!(
143                        provider = %self.provider_id.as_str(),
144                        error = %e,
145                        "fallback.primary.terminal_failure — not retrying"
146                    );
147                    return Err(e);
148                }
149            }
150        } else {
151            tracing::debug!(
152                provider = %self.provider_id.as_str(),
153                "fallback.primary.tripped — breaker open"
154            );
155        }
156
157        self.fallback.extract(episode_id, chunks).await
158    }
159
160    fn applies(&self) -> bool {
161        self.primary.applies() || self.fallback.applies()
162    }
163}
164
165/// Classify a `LunarisError` as transient (retry-worthy via fallback)
166/// or terminal (do not mask). Exposed so tests + downstream callers can
167/// introspect the policy.
168#[must_use]
169pub fn is_transient(err: &LunarisError) -> bool {
170    match err {
171        LunarisError::Storage(StorageError::Backend(_)) => true,
172        LunarisError::Storage(StorageError::NotSupported(_)) => false,
173        LunarisError::Storage(_) => false,
174        LunarisError::Extract(ExtractError::Timeout) => true,
175        LunarisError::Extract(ExtractError::Backend(_)) => true,
176        LunarisError::Extract(ExtractError::GrammarReject(_)) => false,
177        LunarisError::Validate(_) => false,
178        LunarisError::Retrieve(_) => false,
179        LunarisError::Consolidate(_) => false,
180        // LunarisError is #[non_exhaustive] — be conservative on unknown
181        // future variants: treat as terminal so we never silently mask a
182        // novel error class via fallback.
183        _ => false,
184    }
185}
186
187/// Wrap a real extractor with the production fallback floor: a
188/// [`FallbackExtractor`] whose fallback is `NoopExtractor`. This is the seam
189/// `Lunaris::open`'s `default_extractor` calls so the cache-hit extractor is
190/// breaker-guarded — a transient primary failure degrades to empty extraction
191/// (graph extraction off for that episode) instead of failing ingest, while a
192/// terminal error propagates unchanged.
193///
194/// `provider` is the breaker / tracing label (e.g. `"gemma-3-4b-it"`).
195#[must_use]
196pub fn fallback_wrap<P: Extractor>(primary: P, provider: &str) -> Arc<dyn Extractor> {
197    Arc::new(FallbackExtractor::new(primary, crate::NoopExtractor, ProviderId::new(provider)))
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use std::sync::Mutex;
204    use std::sync::atomic::{AtomicUsize, Ordering};
205
206    /// Scripted extractor — pop pre-canned results from the front.
207    /// Tracks call count so tests can assert primary-vs-fallback dispatch.
208    struct ScriptedExtractor {
209        results: Mutex<std::collections::VecDeque<Result<RawExtractionBatch, LunarisError>>>,
210        calls: AtomicUsize,
211        applies: bool,
212    }
213    impl ScriptedExtractor {
214        fn new(results: Vec<Result<RawExtractionBatch, LunarisError>>) -> Self {
215            Self { results: Mutex::new(results.into()), calls: AtomicUsize::new(0), applies: true }
216        }
217        fn calls(&self) -> usize {
218            self.calls.load(Ordering::Relaxed)
219        }
220    }
221    #[async_trait]
222    impl Extractor for ScriptedExtractor {
223        async fn extract(
224            &self,
225            _episode_id: Ulid,
226            _chunks: &[ChunkInput],
227        ) -> Result<RawExtractionBatch, LunarisError> {
228            self.calls.fetch_add(1, Ordering::Relaxed);
229            self.results
230                .lock()
231                .unwrap()
232                .pop_front()
233                .unwrap_or_else(|| Ok(RawExtractionBatch::default()))
234        }
235        fn applies(&self) -> bool {
236            self.applies
237        }
238    }
239
240    fn ok_batch() -> Result<RawExtractionBatch, LunarisError> {
241        Ok(RawExtractionBatch::default())
242    }
243    fn transient_err() -> Result<RawExtractionBatch, LunarisError> {
244        Err(LunarisError::Storage(StorageError::Backend("upstream 503".into())))
245    }
246    fn terminal_err() -> Result<RawExtractionBatch, LunarisError> {
247        Err(LunarisError::Extract(ExtractError::GrammarReject("malformed".into())))
248    }
249
250    #[tokio::test]
251    async fn primary_success_skips_fallback() {
252        let primary = ScriptedExtractor::new(vec![ok_batch()]);
253        let fallback = ScriptedExtractor::new(vec![ok_batch()]);
254        let f = FallbackExtractor::new(primary, fallback, ProviderId::new("test"));
255        let _ = f.extract(Ulid::new(), &[]).await.unwrap();
256        assert_eq!(f.primary.calls(), 1);
257        assert_eq!(f.fallback.calls(), 0, "fallback must not run on primary success");
258    }
259
260    #[tokio::test]
261    async fn transient_failure_falls_through_to_fallback() {
262        let primary = ScriptedExtractor::new(vec![transient_err()]);
263        let fallback = ScriptedExtractor::new(vec![ok_batch()]);
264        let f = FallbackExtractor::new(primary, fallback, ProviderId::new("test"));
265        let _ = f.extract(Ulid::new(), &[]).await.unwrap();
266        assert_eq!(f.primary.calls(), 1);
267        assert_eq!(f.fallback.calls(), 1, "fallback must run on transient primary failure");
268    }
269
270    #[tokio::test]
271    async fn terminal_failure_propagates_without_fallback() {
272        let primary = ScriptedExtractor::new(vec![terminal_err()]);
273        let fallback = ScriptedExtractor::new(vec![ok_batch()]);
274        let f = FallbackExtractor::new(primary, fallback, ProviderId::new("test"));
275        let err = f.extract(Ulid::new(), &[]).await.unwrap_err();
276        assert!(matches!(err, LunarisError::Extract(ExtractError::GrammarReject(_))));
277        assert_eq!(f.primary.calls(), 1);
278        assert_eq!(f.fallback.calls(), 0, "terminal failure must NOT mask via fallback");
279    }
280
281    #[tokio::test]
282    async fn breaker_trips_after_threshold() {
283        // 5 transient failures trip the default breaker. Subsequent calls
284        // skip primary entirely.
285        let primary_calls = vec![
286            transient_err(),
287            transient_err(),
288            transient_err(),
289            transient_err(),
290            transient_err(),
291            // would be the 6th — shouldn't be invoked because breaker is Open
292            ok_batch(),
293        ];
294        let fallback_calls: Vec<_> = (0..6).map(|_| ok_batch()).collect();
295        let primary = ScriptedExtractor::new(primary_calls);
296        let fallback = ScriptedExtractor::new(fallback_calls);
297        let f = FallbackExtractor::new(primary, fallback, ProviderId::new("test"));
298
299        for _ in 0..5 {
300            let _ = f.extract(Ulid::new(), &[]).await.unwrap();
301        }
302        assert_eq!(f.primary.calls(), 5);
303        assert_eq!(f.fallback.calls(), 5);
304
305        // 6th call — primary is tripped, fallback runs directly.
306        let _ = f.extract(Ulid::new(), &[]).await.unwrap();
307        assert_eq!(f.primary.calls(), 5, "primary skipped while breaker Open");
308        assert_eq!(f.fallback.calls(), 6);
309    }
310
311    #[test]
312    fn is_transient_classifier() {
313        assert!(is_transient(&LunarisError::Storage(StorageError::Backend("x".into()))));
314        assert!(is_transient(&LunarisError::Extract(ExtractError::Timeout)));
315        assert!(is_transient(&LunarisError::Extract(ExtractError::Backend("x".into()))));
316        assert!(!is_transient(&LunarisError::Extract(ExtractError::GrammarReject("x".into()))));
317        assert!(!is_transient(&LunarisError::Storage(StorageError::NotSupported("x"))));
318    }
319
320    #[tokio::test]
321    async fn applies_is_true_if_either_arm_applies() {
322        let mut primary = ScriptedExtractor::new(vec![]);
323        primary.applies = false;
324        let fallback = ScriptedExtractor::new(vec![]);
325        let f = FallbackExtractor::new(primary, fallback, ProviderId::new("test"));
326        assert!(f.applies());
327
328        let mut primary2 = ScriptedExtractor::new(vec![]);
329        primary2.applies = false;
330        let mut fallback2 = ScriptedExtractor::new(vec![]);
331        fallback2.applies = false;
332        let f2 = FallbackExtractor::new(primary2, fallback2, ProviderId::new("test"));
333        assert!(!f2.applies());
334    }
335
336    // ── extractor-fallback-wiring (Half A): the `fallback_wrap` production seam ──
337    // `fallback_wrap(primary, provider)` is the exact code `default_extractor`'s
338    // candle cache-hit arm calls; it pins NoopExtractor as the fallback floor.
339    // The return is `Arc<dyn Extractor>` (type-erased), so these assert on
340    // observable behavior, not internal call counts.
341
342    #[tokio::test]
343    async fn fallback_wrap_transient_degrades_to_noop() {
344        let wrapped = fallback_wrap(ScriptedExtractor::new(vec![transient_err()]), "gemma-3-4b-it");
345        let chunks = vec![
346            ChunkInput {
347                chunk_id: Ulid::new(),
348                text: "a".into(),
349                heading_path: vec![],
350                reference_time_iso: None,
351            },
352            ChunkInput {
353                chunk_id: Ulid::new(),
354                text: "b".into(),
355                heading_path: vec![],
356                reference_time_iso: None,
357            },
358        ];
359        let batch = wrapped
360            .extract(Ulid::new(), &chunks)
361            .await
362            .expect("transient primary must degrade to the NoopExtractor floor, not error");
363        assert_eq!(batch.by_chunk.len(), 2, "NoopExtractor emits one empty extraction per chunk");
364        assert!(
365            batch
366                .by_chunk
367                .iter()
368                .all(|r| r.entities.is_empty() && r.relations.is_empty() && r.facts.is_empty()),
369            "the fallback floor produces empty extractions"
370        );
371    }
372
373    #[tokio::test]
374    async fn fallback_wrap_terminal_propagates() {
375        let wrapped = fallback_wrap(ScriptedExtractor::new(vec![terminal_err()]), "gemma-3-4b-it");
376        let err = wrapped
377            .extract(Ulid::new(), &[])
378            .await
379            .expect_err("terminal primary error must propagate, not fall back to Noop");
380        assert!(
381            matches!(err, LunarisError::Extract(ExtractError::GrammarReject(_))),
382            "terminal error propagates unchanged, got {err:?}"
383        );
384    }
385}