Skip to main content

lunaris_extract/
llm_extractor.rs

1//! [`LlmExtractor`] — backend-agnostic [`Extractor`] consuming any
2//! `Arc<dyn LlmBackend>` from `lunaris-llm`.
3//!
4//! ## Why this exists
5//!
6//! Phase 11 unifies the three LLM-using pipelines (extract, verify,
7//! reflect) onto a single trait — `lunaris_llm::LlmBackend`. This module
8//! is the extract-side adapter: it owns the GBNF-instructed prompt
9//! template, the per-batch / per-chunk timeout fallback (D-02), and the
10//! post-hoc JSON parse against the GBNF grammar shape (D-05). Whatever
11//! `LlmBackend` impl the caller picks (`CandleBackend`, `OllamaBackend`,
12//! `CloudBackend`, or a test stub) flows through this one extractor.
13//!
14//! ## Coexistence with the legacy impls
15//!
16//! The pre-existing `crate::CandleGemma3_4B` / [`crate::OllamaExtractor`]
17//! / [`crate::CloudApiExtractor`] are **unchanged** by this commit. They
18//! remain the documented v0.2 backends and continue to load the same
19//! weights / hit the same endpoints. This adapter is *additive*: new
20//! callers can opt in via `Lunaris::with_extractor(Arc::new(
21//! LlmExtractor::new(backend)))` without touching v0.2 paths. The
22//! follow-up commit (per the agreed migration plan) re-implements the
23//! legacy structs as thin wrappers around `LlmExtractor` and deletes the
24//! duplicated load / forward / decode code.
25
26use std::sync::Arc;
27use std::time::Duration;
28
29use async_trait::async_trait;
30use lunaris_core::LunarisError;
31use lunaris_llm::{GenOpts, LlmBackend, SchemaConstraint};
32use serde::Deserialize;
33use ulid::Ulid;
34
35use crate::Extractor;
36use crate::types::{
37    ChunkInput, Entity, EntityId, Fact, RawExtraction, RawExtractionBatch, Relation,
38};
39
40/// Construction options for [`LlmExtractor`]. Mirrors the D-02 budgets
41/// from the existing extract backends so a drop-in swap preserves
42/// behaviour.
43#[derive(Clone, Debug)]
44pub struct LlmExtractorOpts {
45    /// Per-batch timeout — wraps the whole-batch generate call. On
46    /// timeout the extractor falls back to per-chunk extraction.
47    pub batch_timeout_ms: u64,
48    /// Per-chunk timeout for the fallback path. On timeout an empty
49    /// extraction is emitted with `tracing::warn!`.
50    pub per_chunk_timeout_ms: u64,
51    /// Max output tokens passed to [`LlmBackend::generate`].
52    pub max_tokens: u32,
53    /// Sampling temperature (0.0 = greedy).
54    pub temperature: f32,
55    /// Optional GBNF grammar text passed through as
56    /// [`SchemaConstraint::Gbnf`]. The candle backend appends this to
57    /// the prompt; ollama drops it (no GBNF support); cloud-API drops
58    /// it (use `JsonSchema` mode instead). When this is `None`, the
59    /// extractor sends [`SchemaConstraint::None`] — the lighter prompt
60    /// path. Set this when wrapping a legacy v0.2 candle path that
61    /// expected the grammar text inline (preserves D-04 / D-05
62    /// behavior under the wrapper migration).
63    pub gbnf: Option<&'static str>,
64}
65
66impl Default for LlmExtractorOpts {
67    fn default() -> Self {
68        Self {
69            batch_timeout_ms: 150,
70            per_chunk_timeout_ms: 450,
71            max_tokens: 512,
72            temperature: 0.0,
73            gbnf: None,
74        }
75    }
76}
77
78/// Backend-agnostic extractor. Holds an `Arc<dyn LlmBackend>` so the
79/// underlying provider can be swapped at runtime via `LlmConfig`.
80#[derive(Clone)]
81pub struct LlmExtractor {
82    backend: Arc<dyn LlmBackend>,
83    opts: LlmExtractorOpts,
84}
85
86impl std::fmt::Debug for LlmExtractor {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("LlmExtractor")
89            .field("model_id", &self.backend.model_id())
90            .field("opts", &self.opts)
91            .finish()
92    }
93}
94
95impl LlmExtractor {
96    pub fn new(backend: Arc<dyn LlmBackend>) -> Self {
97        Self { backend, opts: LlmExtractorOpts::default() }
98    }
99
100    pub fn with_opts(backend: Arc<dyn LlmBackend>, opts: LlmExtractorOpts) -> Self {
101        Self { backend, opts }
102    }
103
104    /// Single-chunk extract. Builds the prompt, calls the backend, and
105    /// post-hoc parses the JSON. On any error or non-JSON output, emits
106    /// an empty extraction (matches the existing candle behaviour — a
107    /// poisoned extraction is worse than an empty one).
108    async fn extract_one(&self, chunk: &ChunkInput) -> RawExtraction {
109        let prompt = build_prompt(chunk);
110        let gen_opts = GenOpts {
111            max_tokens: self.opts.max_tokens,
112            temperature: self.opts.temperature,
113            timeout: Duration::from_millis(self.opts.per_chunk_timeout_ms),
114        };
115        // Honour `opts.gbnf` when set — this preserves the legacy
116        // CandleGemma3_4B prompt-quality behaviour (which embedded the
117        // ENTITIES_GBNF + RELATIONS_GBNF grammars). When `None`, the
118        // lighter prompt-only path is used; backends that support a
119        // transport-level schema (Ollama JSON-schema / cloud-API
120        // tool-use) are wired separately on a per-call basis.
121        let constraint = match &self.opts.gbnf {
122            Some(g) => SchemaConstraint::Gbnf(g),
123            None => SchemaConstraint::None,
124        };
125        match self.backend.generate(&prompt, constraint, gen_opts).await {
126            Ok(decoded) => parse_extraction_json(&decoded, chunk.chunk_id),
127            Err(e) => {
128                tracing::warn!(
129                    err = %e,
130                    chunk_id = %chunk.chunk_id,
131                    model_id = self.backend.model_id(),
132                    "LlmExtractor generate failed; emitting empty extraction"
133                );
134                RawExtraction { source_chunk_id: chunk.chunk_id, ..Default::default() }
135            }
136        }
137    }
138}
139
140#[async_trait]
141impl Extractor for LlmExtractor {
142    async fn extract(
143        &self,
144        _episode_id: Ulid,
145        chunks: &[ChunkInput],
146    ) -> Result<RawExtractionBatch, LunarisError> {
147        if chunks.is_empty() {
148            return Ok(RawExtractionBatch::default());
149        }
150        // Per-batch timeout (D-02) wraps the whole loop. On timeout we
151        // emit best-effort partials rather than failing the batch.
152        let batch_timeout = Duration::from_millis(self.opts.batch_timeout_ms);
153        let chunks_owned = chunks.to_vec();
154        let this = self.clone();
155        let batch_fut = async move {
156            let mut by_chunk = Vec::with_capacity(chunks_owned.len());
157            for c in &chunks_owned {
158                by_chunk.push(this.extract_one(c).await);
159            }
160            RawExtractionBatch { by_chunk }
161        };
162        match tokio::time::timeout(batch_timeout, batch_fut).await {
163            Ok(b) => Ok(b),
164            Err(_elapsed) => {
165                tracing::warn!(
166                    batch_size = chunks.len(),
167                    timeout_ms = self.opts.batch_timeout_ms,
168                    model_id = self.backend.model_id(),
169                    "LlmExtractor batch timeout; emitting empty per-chunk extractions"
170                );
171                let by_chunk = chunks
172                    .iter()
173                    .map(|c| RawExtraction { source_chunk_id: c.chunk_id, ..Default::default() })
174                    .collect();
175                Ok(RawExtractionBatch { by_chunk })
176            }
177        }
178    }
179
180    fn applies(&self) -> bool {
181        self.backend.applies()
182    }
183}
184
185/// `pub(crate)` so `cloud_api.rs` shares this exact prompt instead of
186/// maintaining its own copy -- it previously had an independent, equally
187/// vague local prompt (same missing-field-names bug, found+fixed
188/// separately during the same review that produced this shared version).
189pub(crate) fn build_prompt(chunk: &ChunkInput) -> String {
190    // T-03-01-01 mitigation: wrap chunk text in `<chunk>` delimiters so
191    // the downstream validator can flag any extracted entity whose name
192    // equals the literal delimiter (prompt-injection guard).
193    //
194    // The explicit field-name shape below matters for backends that reach
195    // this function WITHOUT a GBNF grammar to fall back on. Candle gets the
196    // schema enforced via SchemaConstraint::Gbnf (lunaris-llm's candle.rs
197    // appends the grammar text to the prompt); Ollama drops GBNF entirely
198    // (OllamaExtractor::new() sets gbnf: None) so this prompt text is the
199    // ONLY schema guidance an Ollama/cloud-routed model ever sees. A vague
200    // "respond with {entities:[...],relations:[...]}" placeholder (the
201    // prior text) let a live model guess plausible-but-wrong field names,
202    // failing parse_extraction_json's required-field check on every chunk
203    // (confirmed against MiniMax-M3 via the LongMemEval graph-pipeline
204    // prototype, 2026-07 -- 100% empty-extraction fallback).
205    // Temporal grounding (Mechanism B, 2026-07-29 LME diagnosis + SOTA
206    // comparison tmp/sota_extractor_comparison.md §3, Graphiti REFERENCE_TIME
207    // mechanics): the prior wording "(from the chunk's context, else today)"
208    // MANDATED hallucinated dates — 78% of a 4,882-item cache audit carried
209    // the model's own "today" (2025/2026) against 2022-2023 source text — and
210    // the few-shot example hardcoded "2025-01-01" twice, anchoring even
211    // models that would otherwise abstain. Now: inject the episode's real
212    // date when known, resolve relative expressions against it, and require
213    // null over guessing. The example demonstrates one resolved date + one
214    // null (never a fixed modern date).
215    let reference_block = match chunk.reference_time_iso.as_deref() {
216        Some(d) => format!(
217            "REFERENCE_TIME: {d} (the date the conversation/document in \
218             <chunk> is from)\n\
219             - Resolve relative time expressions against REFERENCE_TIME: \
220             \"yesterday\" = REFERENCE_TIME minus 1 day; \"last week\" = \
221             about 7 days before; \"two years ago\" = 2 years before; \
222             \"today\" / \"this morning\" / \"just now\" = REFERENCE_TIME.\n\
223             - A fact stated in the present tense with no other date (\"I \
224             work at Acme\") is known true as of this conversation: set \
225             valid_from_iso to REFERENCE_TIME.\n\
226             - Never output a date later than REFERENCE_TIME unless the \
227             text explicitly states a future plan.\n"
228        ),
229        None => String::new(),
230    };
231    format!(
232        "Extract entities and relations from the chunk below as JSON.\n\n\
233         Respond with a JSON object of EXACTLY this shape (all fields \
234         required except aliases and valid_to_iso):\n\
235         {{\"entities\":[{{\"name\":\"Alice\",\"entity_type\":\"Person\",\
236         \"aliases\":[],\"confidence\":0.9,\"valid_from_iso\":\"2023-05-14\",\
237         \"valid_to_iso\":null}}],\n\
238         \"relations\":[{{\"subject_name\":\"Alice\",\"subject_type\":\"Person\",\
239         \"predicate\":\"met\",\"object_name\":\"Bob\",\"object_type\":\"Person\",\
240         \"confidence\":0.9,\"valid_from_iso\":null,\"valid_to_iso\":null,\
241         \"fact_text\":\"Alice met Bob at the spring design conference\"}}]}}\n\
242         Use no other field names.\n\
243         Each relation's fact_text is ONE complete natural-language sentence \
244         restating the fact with ALL specific details preserved — proper \
245         nouns, brand and model names, quantities, prices, dates. Paraphrase \
246         the wording, never generalize.\n\
247         Extraction rules:\n\
248         - Chat transcripts are \"<speaker>: <text>\" lines. Attribute each \
249         fact to the correct speaker; the human speaker is the entity \
250         \"user\".\n\
251         - Resolve pronouns to the specific entity name when the chunk makes \
252         it clear; use the most specific form (\"road cycling\" not \
253         \"cycling\", \"the user's sister Anna\" not \"sister\").\n\
254         - NEVER extract pronouns, generic nouns, abstract concepts, or \
255         feelings as entities.\n\
256         {reference_block}\
257         Date rules for valid_from_iso / valid_to_iso (ISO 8601 dates, e.g. \
258         2023-05-14):\n\
259         - If the text states an explicit date (or one resolvable from the \
260         rules above) for when the fact became true, use it. Month and year \
261         only: use the 1st of that month. Year only: use January 1st.\n\
262         - If a fact's start date is genuinely unknown, set valid_from_iso \
263         to null. NEVER invent a date and NEVER infer temporal bounds from \
264         unrelated events.\n\
265         - Set valid_to_iso ONLY when the text says the fact ended, changed, \
266         or was replaced (\"no longer\", \"used to\", \"switched from X to \
267         Y\", \"sold my\"); otherwise null.\n\
268         If nothing is extractable, return \
269         {{\"entities\":[],\"relations\":[]}}.\n\n\
270         <chunk heading=\"{}\">\n{}\n</chunk>",
271        chunk.heading_path.join(" / "),
272        chunk.text
273    )
274}
275
276/// Best-effort JSON parse of the model's decoded output. Tolerant of
277/// trailing junk; extracts the first balanced `{` ... `}` substring.
278///
279/// `pub(crate)` so `cloud_api` can reuse the same parse path when building
280/// its own extraction result (it delegates `generate()` but not the full
281/// `LlmExtractor::extract` path, to preserve the D-21 sentinel contract).
282///
283/// Only callable when the `cloud-api` feature is enabled; the attribute
284/// suppresses a dead_code lint when it is not.
285#[cfg(feature = "cloud-api")]
286pub(crate) fn parse_extraction_json_pub(decoded: &str, chunk_id: Ulid) -> RawExtraction {
287    parse_extraction_json(decoded, chunk_id)
288}
289
290fn parse_extraction_json(decoded: &str, chunk_id: Ulid) -> RawExtraction {
291    let Some(start) = decoded.find('{') else {
292        return RawExtraction { source_chunk_id: chunk_id, ..Default::default() };
293    };
294    let bytes = decoded.as_bytes();
295    let mut depth = 0_i32;
296    let mut end_excl = start;
297    let mut in_string = false;
298    let mut escaped = false;
299    for (i, &b) in bytes.iter().enumerate().skip(start) {
300        if in_string {
301            if escaped {
302                escaped = false;
303            } else if b == b'\\' {
304                escaped = true;
305            } else if b == b'"' {
306                in_string = false;
307            }
308            continue;
309        }
310        match b {
311            b'"' => in_string = true,
312            b'{' => depth += 1,
313            b'}' => {
314                depth -= 1;
315                if depth == 0 {
316                    end_excl = i + 1;
317                    break;
318                }
319            }
320            _ => {}
321        }
322    }
323    if end_excl == start {
324        return RawExtraction { source_chunk_id: chunk_id, ..Default::default() };
325    }
326    let json_slice = &decoded[start..end_excl];
327    match serde_json::from_str::<ExtractionJson>(json_slice) {
328        Ok(parsed) => parsed.into_raw(chunk_id),
329        Err(e) => {
330            tracing::warn!(
331                err = %e,
332                chunk_id = %chunk_id,
333                "LlmExtractor JSON parse failed; emitting empty extraction"
334            );
335            RawExtraction { source_chunk_id: chunk_id, ..Default::default() }
336        }
337    }
338}
339
340/// Deserialize a JSON array element-by-element, silently dropping any element
341/// that fails to deserialize instead of failing the whole array. Cloud
342/// extractors (MiniMax-M3, LongMemEval graph run 2026-07) intermittently emit
343/// one malformed entity/relation per chunk; without this, a single bad element
344/// dropped EVERY good sibling in the same chunk (q1 lost 10 chunks' worth of
345/// extractions this way, silently starving the graph). Design-for-failure: a
346/// bad element degrades to its own omission, never to a whole-chunk loss.
347fn lenient_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
348where
349    D: serde::Deserializer<'de>,
350    T: serde::de::DeserializeOwned,
351{
352    let raw = Vec::<serde_json::Value>::deserialize(deserializer)?;
353    let total = raw.len();
354    let kept: Vec<T> = raw.into_iter().filter_map(|v| serde_json::from_value(v).ok()).collect();
355    if kept.len() < total {
356        tracing::debug!(
357            dropped = total - kept.len(),
358            kept = kept.len(),
359            "lenient_vec: skipped malformed extraction elements"
360        );
361    }
362    Ok(kept)
363}
364
365/// Accept a JSON string, `null`, or an absent field, mapping the empty cases
366/// to `""`. Cloud models routinely null out a `*_type` they cannot classify or
367/// a `valid_from_iso` they cannot date; dropping the whole element over an
368/// un-inferred type loses a real, otherwise-usable fact.
369fn string_or_null<'de, D>(deserializer: D) -> Result<String, D::Error>
370where
371    D: serde::Deserializer<'de>,
372{
373    Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
374}
375
376/// Neutral confidence for elements whose `confidence` the model omitted or
377/// nulled — keeps the element rather than dropping it over a missing score.
378fn mid_confidence() -> f32 {
379    0.5
380}
381
382#[derive(Debug, serde::Deserialize)]
383struct ExtractionJson {
384    #[serde(default, deserialize_with = "lenient_vec")]
385    entities: Vec<EntityJson>,
386    #[serde(default, deserialize_with = "lenient_vec")]
387    relations: Vec<RelationJson>,
388}
389
390#[derive(Debug, serde::Deserialize)]
391struct EntityJson {
392    name: String,
393    #[serde(default, deserialize_with = "string_or_null")]
394    entity_type: String,
395    #[serde(default)]
396    aliases: Vec<String>,
397    #[serde(default = "mid_confidence")]
398    confidence: f32,
399    #[serde(default, deserialize_with = "string_or_null")]
400    valid_from_iso: String,
401    #[serde(default)]
402    valid_to_iso: Option<String>,
403}
404
405#[derive(Debug, serde::Deserialize)]
406struct RelationJson {
407    subject_name: String,
408    #[serde(default, deserialize_with = "string_or_null")]
409    subject_type: String,
410    predicate: String,
411    object_name: String,
412    #[serde(default, deserialize_with = "string_or_null")]
413    object_type: String,
414    #[serde(default = "mid_confidence")]
415    confidence: f32,
416    #[serde(default, deserialize_with = "string_or_null")]
417    valid_from_iso: String,
418    #[serde(default)]
419    valid_to_iso: Option<String>,
420    /// Δ3 (SOTA comparison): model-authored natural-language restatement of
421    /// the relation, detail-preserving. Optional — absent/empty falls back
422    /// to [`synth_fact_text`]'s S-P-O sentence in `into_raw`.
423    #[serde(default, deserialize_with = "string_or_null")]
424    fact_text: String,
425}
426
427impl ExtractionJson {
428    fn into_raw(self, chunk_id: Ulid) -> RawExtraction {
429        let entities = self
430            .entities
431            .into_iter()
432            .map(|e| Entity {
433                id: EntityId::from_name_and_type(&e.name, &e.entity_type),
434                name: e.name,
435                aliases: e.aliases,
436                entity_type: e.entity_type,
437                confidence: e.confidence,
438                valid_from_iso: e.valid_from_iso,
439                valid_to_iso: e.valid_to_iso,
440            })
441            .collect();
442        // Synthesize one Fact per relation: the SAME S-P-O triple the graph
443        // edge carries, plus a readable `fact_text` claim sentence. The graph
444        // extractor never populated the `Fact` primitive, so the `fact:` KV
445        // keyspace and every fact-text consumer was starved. `fact_text` is a
446        // "deduped-but-unsummed" claim (subject + humanized predicate +
447        // object) — exactly the shape the LME reader-context presentation
448        // hypothesis needs. Built from `&self.relations` BEFORE the
449        // `into_iter` below consumes it. Relations with a blank endpoint are
450        // skipped (the validator would reject the fact on empty fact_text
451        // anyway); every kept fact inherits the relation's confidence +
452        // bitemporal window so it passes the same validation gate.
453        let facts = self
454            .relations
455            .iter()
456            .filter(|r| !r.subject_name.trim().is_empty() && !r.object_name.trim().is_empty())
457            .map(|r| Fact {
458                id: Ulid::new(),
459                subject_id: EntityId::from_name_and_type(&r.subject_name, &r.subject_type),
460                predicate: r.predicate.clone(),
461                object_id: EntityId::from_name_and_type(&r.object_name, &r.object_type),
462                // Δ3: prefer the model-authored detail-preserving sentence —
463                // it both cross-encodes and reads far better than the terse
464                // synthesized S-P-O form, which stays as the fallback.
465                fact_text: if r.fact_text.trim().is_empty() {
466                    synth_fact_text(&r.subject_name, &r.predicate, &r.object_name)
467                } else {
468                    r.fact_text.trim().to_owned()
469                },
470                confidence: r.confidence,
471                valid_from_iso: r.valid_from_iso.clone(),
472                valid_to_iso: r.valid_to_iso.clone(),
473            })
474            .collect();
475        let relations = self
476            .relations
477            .into_iter()
478            .map(|r| Relation {
479                subject_id: EntityId::from_name_and_type(&r.subject_name, &r.subject_type),
480                predicate: r.predicate,
481                object_id: EntityId::from_name_and_type(&r.object_name, &r.object_type),
482                confidence: r.confidence,
483                valid_from_iso: r.valid_from_iso,
484                valid_to_iso: r.valid_to_iso,
485            })
486            .collect();
487        RawExtraction { source_chunk_id: chunk_id, entities, relations, facts }
488    }
489}
490
491/// Render an S-P-O triple as a readable claim sentence for `Fact::fact_text`.
492/// The predicate is humanized (SCREAMING_SNAKE / snake_case → lower-cased
493/// words), so `("Alice", "SHOPS_AT", "Store")` becomes `"Alice shops at Store"`.
494fn synth_fact_text(subject: &str, predicate: &str, object: &str) -> String {
495    let pred = predicate.trim().replace('_', " ").to_lowercase();
496    format!("{} {} {}", subject.trim(), pred, object.trim())
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use lunaris_llm::FauxBackend;
503
504    fn chunk(text: &str) -> ChunkInput {
505        ChunkInput {
506            chunk_id: Ulid::new(),
507            heading_path: vec!["section".into()],
508            text: text.into(),
509            reference_time_iso: None,
510        }
511    }
512
513    #[test]
514    fn build_prompt_includes_every_required_json_field_name() {
515        // Candle gets the field-name schema via the in-prompt GBNF grammar
516        // (lunaris-llm/src/candle.rs's SchemaConstraint::Gbnf branch).
517        // Ollama drops GBNF entirely (lunaris-llm/src/ollama.rs: both
518        // SchemaConstraint::None and ::Gbnf map to no `format` field) and
519        // OllamaExtractor::new() sets gbnf: None anyway -- so build_prompt's
520        // own text is the ONLY schema guidance an Ollama/cloud-routed model
521        // ever sees. Confirmed live against MiniMax-M3 (LongMemEval
522        // graph-pipeline prototype, 2026-07): without explicit field names
523        // every single chunk produced plausible-but-wrong JSON that failed
524        // parse_extraction_json's required-field check (EntityJson /
525        // RelationJson have no #[serde(default)] on entity_type/confidence/
526        // valid_from_iso/subject_name/subject_type/predicate/object_name/
527        // object_type), silently degrading to 100% empty extractions.
528        let p = build_prompt(&chunk("Alice met Bob in Paris."));
529        for field in [
530            "entity_type",
531            "confidence",
532            "valid_from_iso",
533            "subject_name",
534            "subject_type",
535            "predicate",
536            "object_name",
537            "object_type",
538        ] {
539            assert!(p.contains(field), "prompt missing required field name: {field}");
540        }
541    }
542
543    #[tokio::test]
544    async fn empty_chunks_returns_empty_batch() {
545        let backend: Arc<dyn LlmBackend> = Arc::new(FauxBackend::new());
546        let extractor = LlmExtractor::new(backend);
547        let out = extractor.extract(Ulid::new(), &[]).await.unwrap();
548        assert!(out.by_chunk.is_empty());
549    }
550
551    #[test]
552    fn synthesizes_a_readable_fact_per_relation() {
553        // The graph extractor (minimax cloud + ollama) only ever emitted
554        // entities + relations; the `facts` array was hard-coded empty, so the
555        // `fact:` KV keyspace stayed empty and every fact-text consumer
556        // (retrieval snippets, the LongMemEval reader context) had nothing to
557        // read — verified live 2026-07-21: a graph-ON haystack produced 161
558        // graph nodes / 75 edges but 0 facts. Each validated relation must now
559        // yield ONE Fact carrying the S-P-O triple AND a readable fact_text.
560        let json = r#"{
561            "entities":[
562                {"name":"Alice","entity_type":"Person","confidence":0.9,"valid_from_iso":"2025-01-01"},
563                {"name":"Racket","entity_type":"Product","confidence":0.9,"valid_from_iso":"2025-01-01"}
564            ],
565            "relations":[
566                {"subject_name":"Alice","subject_type":"Person","predicate":"SHOPS_AT",
567                 "object_name":"Store","object_type":"Org","confidence":0.8,
568                 "valid_from_iso":"2025-01-01"}
569            ]
570        }"#;
571        let raw = parse_extraction_json(json, Ulid::new());
572        assert_eq!(raw.relations.len(), 1);
573        assert_eq!(raw.facts.len(), 1, "exactly one fact synthesized per relation");
574        let f = &raw.facts[0];
575        assert_eq!(f.fact_text, "Alice shops at Store", "readable S-P-O claim sentence");
576        assert_eq!(f.predicate, "SHOPS_AT", "structured predicate preserved verbatim");
577        assert_eq!(f.subject_id, EntityId::from_name_and_type("Alice", "Person"));
578        assert_eq!(f.object_id, EntityId::from_name_and_type("Store", "Org"));
579        assert_eq!(f.confidence, 0.8, "confidence inherited from the relation");
580        assert_eq!(f.valid_from_iso, "2025-01-01");
581    }
582
583    #[tokio::test]
584    async fn parses_valid_json_into_entities() {
585        let backend: Arc<dyn LlmBackend> = Arc::new(FauxBackend::new().with_response(
586            r#"{
587                "entities":[
588                    {"name":"Alice","entity_type":"Person","confidence":0.9,
589                     "valid_from_iso":"2025-01-01"}
590                ],
591                "relations":[]
592            }"#,
593        ));
594        let extractor = LlmExtractor::new(backend);
595        let c = chunk("Alice met Bob in Paris.");
596        let out = extractor.extract(Ulid::new(), &[c]).await.unwrap();
597        assert_eq!(out.by_chunk.len(), 1);
598        assert_eq!(out.by_chunk[0].entities.len(), 1);
599        assert_eq!(out.by_chunk[0].entities[0].name, "Alice");
600    }
601
602    #[tokio::test]
603    async fn malformed_json_emits_empty_extraction() {
604        let backend: Arc<dyn LlmBackend> =
605            Arc::new(FauxBackend::new().with_response("not json at all"));
606        let extractor = LlmExtractor::new(backend);
607        let c = chunk("hello");
608        let out = extractor.extract(Ulid::new(), &[c]).await.unwrap();
609        assert_eq!(out.by_chunk.len(), 1);
610        assert!(out.by_chunk[0].entities.is_empty());
611        assert!(out.by_chunk[0].relations.is_empty());
612    }
613
614    #[tokio::test]
615    async fn one_malformed_element_does_not_drop_the_whole_chunk() {
616        // Real MiniMax-M3 failure modes observed in the LongMemEval graph
617        // run (2026-07): a relation MISSING `subject_type`, a `null` where a
618        // type/date string is expected, and an irrecoverable stub object.
619        // Pre-fix, `serde_json::from_str::<ExtractionJson>` failed on the
620        // WHOLE document and dropped every good sibling too — q1 alone lost
621        // 10 chunks' worth of extractions this way, silently starving the
622        // graph. Each recoverable element must now survive; only the
623        // genuinely-unusable one is skipped.
624        let backend: Arc<dyn LlmBackend> = Arc::new(FauxBackend::new().with_response(
625            r#"{
626                "entities":[
627                    {"name":"Alice","entity_type":"Person","confidence":0.9,"valid_from_iso":"2025-01-01"},
628                    {"name":"Bob","entity_type":null,"confidence":0.8,"valid_from_iso":null}
629                ],
630                "relations":[
631                    {"subject_name":"Alice","predicate":"met","object_name":"Bob","object_type":"Person","confidence":0.9,"valid_from_iso":"2025-01-01"},
632                    {"subject_name":"Alice","subject_type":"Person","predicate":"visited","object_name":"Paris","object_type":"City","confidence":0.7,"valid_from_iso":"2025-01-02"},
633                    {"predicate":"orphan-no-subject-or-object"}
634                ]
635            }"#,
636        ));
637        let extractor = LlmExtractor::new(backend);
638        let c = chunk("Alice met Bob in Paris.");
639        let out = extractor.extract(Ulid::new(), &[c]).await.unwrap();
640        assert_eq!(out.by_chunk.len(), 1);
641        // Both entities survive — Bob's null entity_type/date are tolerated.
642        assert_eq!(out.by_chunk[0].entities.len(), 2, "both entities must survive");
643        // Two relations survive — the first (missing subject_type) is
644        // recovered via default; the orphan (no subject/object name) is the
645        // only element dropped.
646        assert_eq!(
647            out.by_chunk[0].relations.len(),
648            2,
649            "recoverable relations must survive; only the orphan drops"
650        );
651    }
652
653    #[tokio::test]
654    async fn batch_timeout_emits_empty_per_chunk_partials() {
655        // 500 ms delay exceeds the 50 ms batch timeout → timeout path fires.
656        let backend: Arc<dyn LlmBackend> = Arc::new(FauxBackend::new().with_delay_ms(500));
657        let extractor = LlmExtractor::with_opts(
658            backend,
659            LlmExtractorOpts {
660                batch_timeout_ms: 50,
661                per_chunk_timeout_ms: 500,
662                max_tokens: 64,
663                temperature: 0.0,
664                gbnf: None,
665            },
666        );
667        let chunks = vec![chunk("a"), chunk("b"), chunk("c")];
668        let out = extractor.extract(Ulid::new(), &chunks).await.unwrap();
669        // Timeout path emits one empty extraction per input chunk.
670        assert_eq!(out.by_chunk.len(), 3);
671        for r in &out.by_chunk {
672            assert!(r.entities.is_empty());
673        }
674    }
675
676    /// Pin that `LlmExtractorOpts::gbnf` is faithfully threaded through
677    /// to the backend as `SchemaConstraint::Gbnf`.
678    #[tokio::test]
679    async fn gbnf_opt_threads_through_to_backend() {
680        let cap = Arc::new(FauxBackend::new().with_model_id("faux://capturing"));
681        let extractor = LlmExtractor::with_opts(
682            cap.clone() as Arc<dyn LlmBackend>,
683            LlmExtractorOpts { gbnf: Some("root ::= \"{}\""), ..LlmExtractorOpts::default() },
684        );
685        let _ = extractor.extract(Ulid::new(), &[chunk("hi")]).await.unwrap();
686        assert_eq!(cap.last_constraint_tag(), Some("gbnf"));
687    }
688
689    #[tokio::test]
690    async fn no_gbnf_opt_sends_constraint_none() {
691        let cap = Arc::new(FauxBackend::new());
692        let extractor = LlmExtractor::new(cap.clone() as Arc<dyn LlmBackend>);
693        let _ = extractor.extract(Ulid::new(), &[chunk("hi")]).await.unwrap();
694        assert_eq!(cap.last_constraint_tag(), Some("none"));
695    }
696
697    #[test]
698    fn parses_balanced_json_with_trailing_garbage() {
699        let chunk_id = Ulid::new();
700        let raw = parse_extraction_json(
701            r#"junk before {"entities":[],"relations":[]} junk after"#,
702            chunk_id,
703        );
704        assert_eq!(raw.source_chunk_id, chunk_id);
705        assert!(raw.entities.is_empty());
706    }
707
708    // ── Session-date grounding (Mechanism B, N=125 A/B diagnosis 2026-07-29) ──
709    //
710    // Cache-entry audit: 78% of extracted valid_from dates were hallucinated
711    // (3,359/4,882 stamped 2025 + 443 stamped 2026 against 2022-2023
712    // haystacks). Two prompt defects MANDATE that outcome: the instruction
713    // "else today" tells the model to stamp its own today, and the few-shot
714    // example hardcodes "2025-01-01" twice, anchoring even models that would
715    // otherwise abstain. Graphiti-style fix (REFERENCE_TIME injection +
716    // null-over-guess): see tmp/sota_extractor_comparison.md §3.
717
718    fn dated_chunk(text: &str, reference: &str) -> ChunkInput {
719        ChunkInput {
720            chunk_id: Ulid::new(),
721            heading_path: vec!["section".into()],
722            text: text.into(),
723            reference_time_iso: Some(reference.into()),
724        }
725    }
726
727    #[test]
728    fn build_prompt_renders_reference_time_and_temporal_rules() {
729        let p = build_prompt(&dated_chunk("I met Bob yesterday.", "2023-05-30"));
730        assert!(
731            p.contains("REFERENCE_TIME: 2023-05-30"),
732            "prompt must inject the session date as REFERENCE_TIME"
733        );
734        assert!(
735            p.contains("relative time expressions"),
736            "prompt must instruct resolving relative dates against REFERENCE_TIME"
737        );
738        assert!(p.contains("NEVER invent a date"), "prompt must carry the null-over-guess rule");
739        assert!(!p.contains("else today"), "the hallucination mandate must be gone");
740        assert!(
741            !p.contains("2025-01-01"),
742            "few-shot example dates must not anchor the model to 2025"
743        );
744    }
745
746    // ── Δ3 + Δ5 (SOTA comparison, tmp/sota_extractor_comparison.md) ──
747    //
748    // Δ3: fact_text is currently code-synthesized terse SVO ("Alice bought
749    // Wilson Pro Staff") — exactly the shape that scores near-zero under the
750    // cross-encoder against natural-language questions and that poisoned the
751    // FACT_HITS reader block. Graphiti-style fix: the model authors
752    // fact_text as ONE complete sentence preserving every specific detail;
753    // the synthesized form stays as fallback only.
754    // Δ5: extraction hygiene (Graphiti extract_nodes rules) — speaker
755    // attribution for chat chunks, pronoun resolution, most-specific-form,
756    // and a negative constraint against pronoun/generic/abstract entities.
757    // Both ride the SAME cache refill as the temporal rewrite.
758
759    #[test]
760    fn prompt_asks_for_llm_authored_fact_text_and_hygiene() {
761        let p = build_prompt(&chunk("user: I bought a Wilson racket."));
762        assert!(p.contains("fact_text"), "relation schema must include fact_text");
763        assert!(
764            p.contains("never generalize"),
765            "detail-preservation rule (proper nouns/quantities/dates survive)"
766        );
767        assert!(
768            p.contains("NEVER extract pronouns"),
769            "negative constraint: no pronoun/generic/abstract entities"
770        );
771        assert!(p.contains("speaker"), "chat speaker-attribution rule");
772    }
773
774    #[test]
775    fn llm_authored_fact_text_preferred_with_synth_fallback() {
776        let chunk_id = Ulid::new();
777        let raw = parse_extraction_json(
778            r#"{"entities":[],"relations":[
779                {"subject_name":"Alice","subject_type":"Person","predicate":"BOUGHT",
780                 "object_name":"Wilson Pro Staff","object_type":"Product","confidence":0.9,
781                 "valid_from_iso":"2023-05-14","valid_to_iso":null,
782                 "fact_text":"Alice bought a Wilson Pro Staff racket at Tennis Warehouse in May 2023"},
783                {"subject_name":"Alice","subject_type":"Person","predicate":"LIVES_IN",
784                 "object_name":"Boston","object_type":"City","confidence":0.9,
785                 "valid_from_iso":null,"valid_to_iso":null}
786            ]}"#,
787            chunk_id,
788        );
789        assert_eq!(raw.facts.len(), 2);
790        assert_eq!(
791            raw.facts[0].fact_text,
792            "Alice bought a Wilson Pro Staff racket at Tennis Warehouse in May 2023",
793            "model-authored fact_text must be used verbatim"
794        );
795        assert_eq!(
796            raw.facts[1].fact_text, "Alice lives in Boston",
797            "absent/empty fact_text falls back to the synthesized S-P-O sentence"
798        );
799    }
800
801    #[test]
802    fn build_prompt_without_reference_time_uses_null_policy() {
803        let p = build_prompt(&chunk("Alice met Bob in Paris."));
804        assert!(
805            !p.contains("REFERENCE_TIME:"),
806            "no reference time available -> no REFERENCE_TIME line"
807        );
808        assert!(
809            p.contains("NEVER invent a date"),
810            "null-over-guess must hold even without a reference time"
811        );
812        assert!(!p.contains("else today"));
813        assert!(!p.contains("2025-01-01"));
814    }
815}