Skip to main content

lunaris_extract/
validator.rs

1//! Episode-local Validator (D-08). Catches three classes of failure:
2//!
3//! 1. **Bi-temporal sanity** — `valid_from >= valid_to`
4//! 2. **Structural contradictions within an Episode** — same `(subject_id,
5//!    predicate)` with overlapping `[valid_from, valid_to]` and conflicting
6//!    `object_id`s
7//! 3. **GBNF post-validation** — parsed entity / relation / fact must satisfy
8//!    the schema (non-empty name, non-empty entity_type / predicate, confidence
9//!    in `[0, 1]`)
10//!
11//! Plus a fourth class that's the result of upstream backend failure rather
12//! than schema invalidity:
13//!
14//! 4. **Transient-after-retry** — the cloud-api backend (D-21) emits a sentinel
15//!    `Entity { entity_type: "__lunaris_sentinel__", name:
16//!    "__transient_after_retry__", valid_from_iso: "transient: <error>" }` when
17//!    the single retry exhausts; the validator detects this sentinel by
18//!    `entity_type` and routes it to
19//!    [`NeedsReviewReason::TransientAfterRetry`] carrying the wrapped error
20//!    text. The sentinel NEVER lands in [`ValidatedExtraction::entities`].
21//!
22//! Cross-Episode contradictions (e.g., two Episodes claim different birth
23//! years for Alice) are deferred to the Phase 4 Verifier per D-09 — out of
24//! scope for v0. The validator IS Episode-local but works across all chunks
25//! in a [`crate::RawExtractionBatch`].
26//!
27//! ## NeedsReview never silently dropped
28//!
29//! Per EXTRACT-05 + ROADMAP success criteria #2: invalid items are routed to
30//! [`ValidatedExtraction::needs_review`] with a structured reason — there is
31//! no "skip validator" call site for the extractor backends. Plan 03-03's
32//! ingest fan-out reads ONLY `validated.entities/relations/facts` for
33//! WriteOps; needs_review items emit a `__lunaris_verify__` MQ message via
34//! `StoragePort::publish` so the Phase 4 Verifier worker can pick them up
35//! later (D-19 Phase 4 hook).
36
37#![allow(clippy::too_many_lines)]
38
39use std::collections::{HashMap, HashSet};
40
41use serde::{Deserialize, Serialize};
42use thiserror::Error;
43use ulid::Ulid;
44
45use crate::types::{Entity, EntityId, ExtractionBatch, Fact, RawExtractionBatch, Relation};
46
47/// Sentinel reserved for the cloud-api retry-exhaust path (D-21 + W-1 fix).
48/// The cloud-api backend emits an [`Entity`] with this `entity_type` and the
49/// reserved `name` below to communicate "transient failure after 1 retry" up
50/// to the validator without altering the trait return type. The validator
51/// detects the pair and routes to [`NeedsReviewReason::TransientAfterRetry`].
52pub const TRANSIENT_SENTINEL_TYPE: &str = "__lunaris_sentinel__";
53/// See [`TRANSIENT_SENTINEL_TYPE`].
54pub const TRANSIENT_SENTINEL_NAME: &str = "__transient_after_retry__";
55
56/// Structured reason explaining why an extraction was routed to
57/// [`ValidatedExtraction::needs_review`]. Each variant maps onto a
58/// blueprint §5.2 / D-08 / D-21 NeedsReview class.
59///
60/// Implements [`std::error::Error`] via `thiserror` so callers can format the
61/// reason directly into a `tracing::warn!` event.
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Error)]
63pub enum NeedsReviewReason {
64    /// `valid_from >= valid_to` for an [`Entity`] / [`Relation`] / [`Fact`].
65    #[error("invalid bi-temporal interval: valid_from {valid_from} >= valid_to {valid_to:?}")]
66    InvalidBitemporal { valid_from: String, valid_to: Option<String> },
67
68    /// Two or more [`Relation`]s on the same `(subject_id, predicate)` with
69    /// overlapping `[valid_from, valid_to]` and conflicting `object_id`s.
70    /// Per D-08 the validator demotes ALL conflicting entries (can't pick a
71    /// winner without the Phase 4 Verifier).
72    #[error(
73        "structural contradiction: ({subject}, {predicate}) has overlapping objects {conflict:?}"
74    )]
75    StructuralContradiction { subject: EntityId, predicate: String, conflict: Vec<EntityId> },
76
77    /// Parsed extraction violates the GBNF schema (empty name, empty
78    /// predicate, empty fact_text, confidence outside `[0, 1]`).
79    #[error("GBNF schema failure at {schema_path}: {error}")]
80    GbnfFailure { schema_path: String, error: String },
81
82    /// Cloud-API extractor exhausted its single retry budget per D-21.
83    /// Carries the wrapped error text so the Phase 4 Verifier worker can
84    /// triage which provider+model failed.
85    #[error("extractor transient failure after 1 retry: {0}")]
86    TransientAfterRetry(String),
87
88    /// A newly-ingested fact asserts a DIFFERENT object for an existing
89    /// `(subject, predicate)` whose validity window OVERLAPS the new one —
90    /// detected at structured ingest by reading the in-scope
91    /// `fact_spo_key` index (memory-update convergence). Unlike
92    /// [`Self::StructuralContradiction`] (single-Episode, demote-both) this is
93    /// CROSS-Episode and carries BOTH fact ids so the Phase 4 Verifier can
94    /// arbitrate a winner/loser via `cross_episode_decision` → `apply_supersede`
95    /// (latest-assertion-wins), closing the loser's bi-temporal interval.
96    #[error(
97        "cross-episode contradiction: ({subject}, {predicate}) existing object {existing_object} \
98         (fact {existing_fact_id}) vs new object {new_object} (fact {new_fact_id})"
99    )]
100    CrossEpisodeContradiction {
101        subject: EntityId,
102        predicate: String,
103        existing_fact_id: Ulid,
104        existing_object: EntityId,
105        new_fact_id: Ulid,
106        new_object: EntityId,
107    },
108}
109
110/// One failed extraction with the reason it was demoted. Preserves the raw
111/// item so the Phase 4 Verifier (Plan 04-XX) can re-extract / arbitrate.
112#[derive(Clone, Debug, PartialEq)]
113pub enum NeedsReviewItem {
114    Entity { reason: NeedsReviewReason, raw: Entity },
115    Relation { reason: NeedsReviewReason, raw: Relation },
116    Fact { reason: NeedsReviewReason, raw: Fact },
117}
118
119/// What [`validate`] produces. The `entities`/`relations`/`facts` lists hold
120/// items that passed validation and are safe to write to storage; the
121/// `needs_review` list holds items that failed with a structured reason so the
122/// ingest fan-out can publish them to the verify queue (D-19).
123#[derive(Clone, Debug, Default, PartialEq)]
124pub struct ValidatedExtraction {
125    pub entities: Vec<Entity>,
126    pub relations: Vec<Relation>,
127    pub facts: Vec<Fact>,
128    pub needs_review: Vec<NeedsReviewItem>,
129}
130
131/// Validate an Episode-local extraction batch. Routes invalid items to
132/// `needs_review` with a structured reason; never drops silently.
133///
134/// See module rustdoc for the four classes of failure handled.
135pub fn validate(batch: RawExtractionBatch) -> ValidatedExtraction {
136    let mut out = ValidatedExtraction::default();
137
138    // (object_id, (valid_from, valid_to)) — one entry per relation in a
139    // (subject_id, predicate) bucket. The interval bound is kept as Strings
140    // so the lex-sort comparison matches the bi-temporal sanity path above.
141    type RelationInterval = (EntityId, (String, Option<String>));
142
143    // (subject_id, predicate) → Vec<RelationInterval>. Built during the
144    // relation walk; used after the per-chunk loop to detect structural
145    // contradictions WITHIN this Episode.
146    let mut bucket: HashMap<(EntityId, String), Vec<RelationInterval>> = HashMap::new();
147
148    for chunk in batch.by_chunk {
149        // ---------- Entities ----------
150        for e in chunk.entities {
151            // (4) Sentinel detection — D-21 cloud-api retry-exhaust marker.
152            // Route to TransientAfterRetry with the wrapped error text from
153            // valid_from_iso. The sentinel is checked BEFORE the bi-temporal
154            // / GBNF checks so it never lands in `out.entities`.
155            if e.entity_type == TRANSIENT_SENTINEL_TYPE && e.name == TRANSIENT_SENTINEL_NAME {
156                let err_text = e
157                    .valid_from_iso
158                    .strip_prefix("transient: ")
159                    .map(str::to_string)
160                    .unwrap_or_else(|| e.valid_from_iso.clone());
161                out.needs_review.push(NeedsReviewItem::Entity {
162                    reason: NeedsReviewReason::TransientAfterRetry(err_text),
163                    raw: e,
164                });
165                continue;
166            }
167
168            // (1) Bi-temporal sanity. RFC3339 with the same offset (`Z`) lex-
169            // sorts correctly; same-offset is the convention emitted by the
170            // GBNF grammar (entities.gbnf valid_from_iso/valid_to_iso fields).
171            if let Some(vt) = &e.valid_to_iso
172                && &e.valid_from_iso >= vt
173            {
174                out.needs_review.push(NeedsReviewItem::Entity {
175                    reason: NeedsReviewReason::InvalidBitemporal {
176                        valid_from: e.valid_from_iso.clone(),
177                        valid_to: e.valid_to_iso.clone(),
178                    },
179                    raw: e,
180                });
181                continue;
182            }
183
184            // (3) GBNF post-validation. Empty name / empty type / out-of-range
185            // confidence are all schema violations — even if grammar-constrained
186            // sampling was used, the post-hoc check catches the case where
187            // the GBNF binding silently failed (D-05 fallback path).
188            if e.name.is_empty() || e.entity_type.is_empty() || !(0.0..=1.0).contains(&e.confidence)
189            {
190                let err = format!(
191                    "invalid name/type/confidence: name={:?} type={:?} conf={}",
192                    e.name, e.entity_type, e.confidence
193                );
194                out.needs_review.push(NeedsReviewItem::Entity {
195                    reason: NeedsReviewReason::GbnfFailure {
196                        schema_path: "entities.gbnf".into(),
197                        error: err,
198                    },
199                    raw: e,
200                });
201                continue;
202            }
203
204            out.entities.push(e);
205        }
206
207        // ---------- Relations ----------
208        for r in chunk.relations {
209            // (1) Bi-temporal sanity
210            if let Some(vt) = &r.valid_to_iso
211                && &r.valid_from_iso >= vt
212            {
213                out.needs_review.push(NeedsReviewItem::Relation {
214                    reason: NeedsReviewReason::InvalidBitemporal {
215                        valid_from: r.valid_from_iso.clone(),
216                        valid_to: r.valid_to_iso.clone(),
217                    },
218                    raw: r,
219                });
220                continue;
221            }
222
223            // (3) GBNF post-validation
224            if r.predicate.is_empty() || !(0.0..=1.0).contains(&r.confidence) {
225                let err = format!(
226                    "invalid predicate/confidence: pred={:?} conf={}",
227                    r.predicate, r.confidence
228                );
229                out.needs_review.push(NeedsReviewItem::Relation {
230                    reason: NeedsReviewReason::GbnfFailure {
231                        schema_path: "relations.gbnf".into(),
232                        error: err,
233                    },
234                    raw: r,
235                });
236                continue;
237            }
238
239            // Track for episode-local contradiction detection (D-08 #2).
240            // We push BEFORE landing in `out.relations` so the bucket sees the
241            // full set; the demotion pass below moves any conflicting entries
242            // out of `out.relations` and into `needs_review`.
243            bucket
244                .entry((r.subject_id, r.predicate.clone()))
245                .or_default()
246                .push((r.object_id, (r.valid_from_iso.clone(), r.valid_to_iso.clone())));
247
248            out.relations.push(r);
249        }
250
251        // ---------- Facts ----------
252        for f in chunk.facts {
253            // (1) Bi-temporal sanity
254            if let Some(vt) = &f.valid_to_iso
255                && &f.valid_from_iso >= vt
256            {
257                out.needs_review.push(NeedsReviewItem::Fact {
258                    reason: NeedsReviewReason::InvalidBitemporal {
259                        valid_from: f.valid_from_iso.clone(),
260                        valid_to: f.valid_to_iso.clone(),
261                    },
262                    raw: f,
263                });
264                continue;
265            }
266
267            // (3) GBNF post-validation. Facts ALSO require non-empty fact_text
268            // — an empty fact body is useless to retrieval.
269            if f.predicate.is_empty()
270                || f.fact_text.is_empty()
271                || !(0.0..=1.0).contains(&f.confidence)
272            {
273                let err = format!(
274                    "invalid predicate/fact_text/confidence: pred={:?} fact_text_len={} conf={}",
275                    f.predicate,
276                    f.fact_text.len(),
277                    f.confidence
278                );
279                out.needs_review.push(NeedsReviewItem::Fact {
280                    reason: NeedsReviewReason::GbnfFailure {
281                        schema_path: "relations.gbnf".into(),
282                        error: err,
283                    },
284                    raw: f,
285                });
286                continue;
287            }
288
289            out.facts.push(f);
290        }
291    }
292
293    // ---------- (2) Episode-local structural contradiction detection ----------
294    //
295    // For each (subject_id, predicate) bucket, check whether any two entries
296    // have OVERLAPPING [valid_from, valid_to] AND DIFFERENT object_ids. If so,
297    // demote ALL entries in the bucket to `needs_review` with the
298    // StructuralContradiction reason (per-bucket all-or-nothing because we
299    // can't pick a winner without the Phase 4 Verifier; D-09).
300    let mut to_demote: HashSet<(EntityId, String)> = HashSet::new();
301    for ((subj, pred), entries) in &bucket {
302        if entries.len() < 2 {
303            continue;
304        }
305        // Pairwise overlap check — O(n^2) per bucket. Episode-local buckets
306        // are tiny (typical chunk yields < 10 relations on the same
307        // subject/predicate), so the quadratic cost is fine.
308        let mut found_conflict = false;
309        for i in 0..entries.len() {
310            if found_conflict {
311                break;
312            }
313            for j in (i + 1)..entries.len() {
314                let (obj_i, (vf_i, vt_i)) = &entries[i];
315                let (obj_j, (vf_j, vt_j)) = &entries[j];
316                if obj_i == obj_j {
317                    // Same object — restating, not contradicting.
318                    continue;
319                }
320                // Overlap if max(vf_i, vf_j) < min(vt_i, vt_j); treat None as +infinity.
321                let later_start = std::cmp::max(vf_i, vf_j);
322                let earlier_end_opt: Option<&String> = match (vt_i, vt_j) {
323                    (Some(a), Some(b)) => Some(std::cmp::min(a, b)),
324                    (Some(a), None) => Some(a),
325                    (None, Some(b)) => Some(b),
326                    (None, None) => None,
327                };
328                let overlaps = match earlier_end_opt {
329                    Some(end) => later_start < end,
330                    None => true,
331                };
332                if overlaps {
333                    to_demote.insert((*subj, pred.clone()));
334                    found_conflict = true;
335                    break;
336                }
337            }
338        }
339    }
340
341    if !to_demote.is_empty() {
342        let kept = std::mem::take(&mut out.relations);
343        for r in kept {
344            let key = (r.subject_id, r.predicate.clone());
345            if to_demote.contains(&key) {
346                let conflict: Vec<EntityId> = bucket
347                    .get(&key)
348                    .map(|v| v.iter().map(|(o, _)| *o).collect())
349                    .unwrap_or_default();
350                out.needs_review.push(NeedsReviewItem::Relation {
351                    reason: NeedsReviewReason::StructuralContradiction {
352                        subject: r.subject_id,
353                        predicate: r.predicate.clone(),
354                        conflict,
355                    },
356                    raw: r,
357                });
358            } else {
359                out.relations.push(r);
360            }
361        }
362    }
363
364    // ---------- KG-RAG Wave D (2026-07-21): duplicate-triple canonicalization
365    //
366    // The same assertion extracted from adjacent chunks (or restated in one
367    // session) otherwise lands as N fact rows + N vector entries and crowds
368    // the RRF fusion window with copies. Collapse survivors that agree on the
369    // FULL identity key — (subject, predicate, object, valid_from, valid_to) —
370    // keeping the MAX confidence at the first-seen position. Differing
371    // validity intervals are distinct temporal assertions and both survive.
372    // This is canonicalization of redundancy, NOT validation rejection:
373    // EXTRACT-05's needs_review routing for invalid items is untouched.
374    dedup_by_key(&mut out.facts, |f| {
375        (
376            f.subject_id,
377            f.predicate.clone(),
378            f.object_id,
379            f.valid_from_iso.clone(),
380            f.valid_to_iso.clone(),
381        )
382    });
383    dedup_by_key(&mut out.relations, |r| {
384        (
385            r.subject_id,
386            r.predicate.clone(),
387            r.object_id,
388            r.valid_from_iso.clone(),
389            r.valid_to_iso.clone(),
390        )
391    });
392
393    out
394}
395
396/// Collapse items sharing an identity key to one survivor at the first-seen
397/// position, carrying the maximum confidence seen for that key. Order of
398/// distinct keys is preserved (deterministic output for identical input).
399fn dedup_by_key<T, K, F>(items: &mut Vec<T>, key_of: F)
400where
401    K: std::hash::Hash + Eq,
402    F: Fn(&T) -> K,
403    T: HasConfidence,
404{
405    use std::collections::HashMap;
406    let taken = std::mem::take(items);
407    let mut index_of_key: HashMap<K, usize> = HashMap::new();
408    let mut deduped_count = 0usize;
409    for item in taken {
410        match index_of_key.entry(key_of(&item)) {
411            std::collections::hash_map::Entry::Vacant(e) => {
412                e.insert(items.len());
413                items.push(item);
414            }
415            std::collections::hash_map::Entry::Occupied(e) => {
416                let survivor = &mut items[*e.get()];
417                if item.confidence() > survivor.confidence() {
418                    survivor.set_confidence(item.confidence());
419                }
420                deduped_count += 1;
421            }
422        }
423    }
424    if deduped_count > 0 {
425        tracing::debug!(deduped = deduped_count, "validator_dedup_collapsed_duplicates");
426    }
427}
428
429/// Confidence accessor for [`dedup_by_key`]'s max-confidence merge.
430trait HasConfidence {
431    fn confidence(&self) -> f32;
432    fn set_confidence(&mut self, c: f32);
433}
434
435impl HasConfidence for Fact {
436    fn confidence(&self) -> f32 {
437        self.confidence
438    }
439    fn set_confidence(&mut self, c: f32) {
440        self.confidence = c;
441    }
442}
443
444impl HasConfidence for Relation {
445    fn confidence(&self) -> f32 {
446        self.confidence
447    }
448    fn set_confidence(&mut self, c: f32) {
449        self.confidence = c;
450    }
451}
452
453/// Deterministic future-date backstop (Mechanism B, 2026-07-29 LME
454/// diagnosis; see `tmp/sota_extractor_comparison.md` §3 rider 2).
455///
456/// Even with `REFERENCE_TIME` in the extraction prompt, a model can still
457/// stamp its own "today" (the observed 2025/2026 hallucination class — 78%
458/// of a 4,882-item cache audit). This pass mechanically caps any
459/// `valid_from_iso` whose DATE PART (first 10 chars, `YYYY-MM-DD`) is later
460/// than `reference_date` back to `reference_date` — semantically "known
461/// true as of this conversation", which is exactly what a present-tense
462/// hallucinated stamp meant.
463///
464/// Left untouched: items with an explicit `valid_to_iso` (a stated, bounded
465/// future plan), same-day full timestamps (date-part comparison, never
466/// lexicographic on the whole string), past dates, and empty/garbage values
467/// that don't parse as a 10-char date prefix (the validator's sanity pass
468/// owns those).
469///
470/// Call at the ingest boundary AFTER extraction (and therefore after any
471/// extraction-cache replay) so hallucinated dates in already-cached raw
472/// extractions are capped too.
473pub fn cap_future_valid_from(batch: &mut RawExtractionBatch, reference_date: &str) {
474    let ref_date = reference_date.get(..10).unwrap_or(reference_date);
475
476    fn cap_one(valid_from: &mut String, valid_to: &Option<String>, ref_date: &str) {
477        if valid_to.as_deref().is_some_and(|t| !t.is_empty()) {
478            return;
479        }
480        let Some(date_part) = valid_from.get(..10) else {
481            return;
482        };
483        let looks_like_date = date_part.as_bytes()[4] == b'-'
484            && date_part.as_bytes()[7] == b'-'
485            && date_part[..4].bytes().all(|b| b.is_ascii_digit());
486        if looks_like_date && date_part > ref_date {
487            *valid_from = ref_date.to_owned();
488        }
489    }
490
491    for raw in &mut batch.by_chunk {
492        for e in &mut raw.entities {
493            cap_one(&mut e.valid_from_iso, &e.valid_to_iso, ref_date);
494        }
495        for r in &mut raw.relations {
496            cap_one(&mut r.valid_from_iso, &r.valid_to_iso, ref_date);
497        }
498        for f in &mut raw.facts {
499            cap_one(&mut f.valid_from_iso, &f.valid_to_iso, ref_date);
500        }
501    }
502}
503
504/// Project a [`ValidatedExtraction`] into a flat [`ExtractionBatch`] (the shape
505/// the Plan 03-03 ingest fan-out converts into `WriteOp`s). `needs_review` is
506/// dropped by this conversion — the caller is expected to publish those items
507/// to `__lunaris_verify__` separately per D-19.
508pub fn into_batch(v: &ValidatedExtraction) -> ExtractionBatch {
509    ExtractionBatch {
510        entities: v.entities.clone(),
511        relations: v.relations.clone(),
512        facts: v.facts.clone(),
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    //! Smoke tests live here; the full table-driven Validator suite is in
519    //! `tests/validator.rs` (Task 3 of Plan 03-01).
520    use super::*;
521    use crate::types::RawExtraction;
522    use ulid::Ulid;
523
524    #[test]
525    fn empty_batch_produces_empty_validated() {
526        let v = validate(RawExtractionBatch::default());
527        assert!(v.entities.is_empty());
528        assert!(v.relations.is_empty());
529        assert!(v.facts.is_empty());
530        assert!(v.needs_review.is_empty());
531    }
532
533    // ── Deterministic future-date backstop (Mechanism B, 2026-07-29) ──
534    //
535    // Even with REFERENCE_TIME in the prompt, a model can still stamp its
536    // own "today" (the observed 2025/2026 class, 78% of the audited cache).
537    // The backstop mechanically caps any valid_from LATER than the episode's
538    // reference date back to the reference date — semantically "known true
539    // as of this conversation", which is exactly what a present-tense
540    // hallucinated stamp meant. Items with an explicit valid_to are left
541    // alone (a stated, bounded future plan). Applied at the ingest boundary
542    // (AFTER cache replay, so hallucinated dates in already-cached raw
543    // extractions get capped too).
544
545    fn raw_with_dates(from: &str, to: Option<&str>) -> RawExtractionBatch {
546        let e = Entity {
547            id: EntityId::from_name_and_type("Alice", "Person"),
548            name: "Alice".into(),
549            aliases: vec![],
550            entity_type: "Person".into(),
551            confidence: 0.9,
552            valid_from_iso: from.into(),
553            valid_to_iso: to.map(str::to_owned),
554        };
555        let r = Relation {
556            subject_id: EntityId::from_name_and_type("Alice", "Person"),
557            predicate: "works_at".into(),
558            object_id: EntityId::from_name_and_type("Acme", "Org"),
559            confidence: 0.9,
560            valid_from_iso: from.into(),
561            valid_to_iso: to.map(str::to_owned),
562        };
563        RawExtractionBatch {
564            by_chunk: vec![RawExtraction {
565                source_chunk_id: Ulid::new(),
566                entities: vec![e],
567                relations: vec![r],
568                facts: vec![],
569            }],
570        }
571    }
572
573    #[test]
574    fn future_valid_from_capped_to_reference_date() {
575        // The observed hallucination class: model stamps its own today.
576        let mut batch = raw_with_dates("2025-01-30", None);
577        cap_future_valid_from(&mut batch, "2023-05-30");
578        let raw = &batch.by_chunk[0];
579        assert_eq!(raw.entities[0].valid_from_iso, "2023-05-30");
580        assert_eq!(raw.relations[0].valid_from_iso, "2023-05-30");
581    }
582
583    #[test]
584    fn past_and_same_day_valid_from_untouched() {
585        let mut batch = raw_with_dates("2022-11-02", None);
586        cap_future_valid_from(&mut batch, "2023-05-30");
587        assert_eq!(batch.by_chunk[0].entities[0].valid_from_iso, "2022-11-02");
588
589        // Same-day full RFC3339 timestamp must NOT count as future (date-part
590        // comparison, not lexicographic on the whole string).
591        let mut batch = raw_with_dates("2023-05-30T23:40:00Z", None);
592        cap_future_valid_from(&mut batch, "2023-05-30");
593        assert_eq!(batch.by_chunk[0].entities[0].valid_from_iso, "2023-05-30T23:40:00Z");
594    }
595
596    #[test]
597    fn explicit_future_plan_with_valid_to_untouched() {
598        let mut batch = raw_with_dates("2023-08-01", Some("2023-09-01"));
599        cap_future_valid_from(&mut batch, "2023-05-30");
600        assert_eq!(
601            batch.by_chunk[0].entities[0].valid_from_iso, "2023-08-01",
602            "a bounded, explicitly stated future plan must survive the cap"
603        );
604    }
605
606    #[test]
607    fn empty_and_garbage_valid_from_untouched() {
608        let mut batch = raw_with_dates("", None);
609        cap_future_valid_from(&mut batch, "2023-05-30");
610        assert_eq!(batch.by_chunk[0].entities[0].valid_from_iso, "");
611
612        // Non-date junk (shorter than a date, non-numeric) is the
613        // validator's problem, not the cap's — leave it for the sanity pass.
614        let mut batch = raw_with_dates("unknown", None);
615        cap_future_valid_from(&mut batch, "2023-05-30");
616        assert_eq!(batch.by_chunk[0].entities[0].valid_from_iso, "unknown");
617    }
618
619    #[test]
620    fn into_batch_drops_needs_review() {
621        let v = ValidatedExtraction {
622            entities: vec![],
623            relations: vec![],
624            facts: vec![],
625            needs_review: vec![NeedsReviewItem::Entity {
626                reason: NeedsReviewReason::TransientAfterRetry("HTTP 503".into()),
627                raw: Entity {
628                    id: EntityId::from_name_and_type(
629                        TRANSIENT_SENTINEL_NAME,
630                        TRANSIENT_SENTINEL_TYPE,
631                    ),
632                    name: TRANSIENT_SENTINEL_NAME.into(),
633                    aliases: vec![],
634                    entity_type: TRANSIENT_SENTINEL_TYPE.into(),
635                    confidence: 0.0,
636                    valid_from_iso: "transient: HTTP 503".into(),
637                    valid_to_iso: None,
638                },
639            }],
640        };
641        let b = into_batch(&v);
642        assert!(b.entities.is_empty());
643        assert!(b.relations.is_empty());
644        assert!(b.facts.is_empty());
645    }
646
647    #[test]
648    fn happy_path_single_entity_passes() {
649        let entity = Entity {
650            id: EntityId::from_name_and_type("Alice", "Person"),
651            name: "Alice".into(),
652            aliases: vec![],
653            entity_type: "Person".into(),
654            confidence: 0.9,
655            valid_from_iso: "2024-01-01T00:00:00Z".into(),
656            valid_to_iso: None,
657        };
658        let batch = RawExtractionBatch {
659            by_chunk: vec![RawExtraction {
660                source_chunk_id: Ulid::new(),
661                entities: vec![entity.clone()],
662                relations: vec![],
663                facts: vec![],
664            }],
665        };
666        let v = validate(batch);
667        assert_eq!(v.entities, vec![entity]);
668        assert!(v.needs_review.is_empty());
669    }
670}